From 8159703650c2af37d4374c9b8f7c7d5d7b8a33c9 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 8 Oct 2026 10:59:28 +0100 Subject: [PATCH] Add flat campaign decision history and explicit capture workflow --- .github/workflows/dashboard_refresh.yml | 2 + CHECKIN.md | 22 +++++ REFERENCE.md | 9 ++ dashboard.html | 2 +- dashboard.md | 6 ++ decisions.yaml | 2 + decisions/README.md | 68 +++++++++++++++ pulse/board.py | 6 +- pulse/cli.py | 14 +++- pulse/decisions.py | 106 ++++++++++++++++++++++++ tests/browser_fixture.py | 13 +++ tests/browser_setup.cjs | 11 +++ tests/test_cli.py | 24 ++++++ tests/test_decisions.py | 96 +++++++++++++++++++++ 14 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 decisions.yaml create mode 100644 decisions/README.md create mode 100644 pulse/decisions.py create mode 100644 tests/test_decisions.py diff --git a/.github/workflows/dashboard_refresh.yml b/.github/workflows/dashboard_refresh.yml index 7f8c30d..afb7599 100644 --- a/.github/workflows/dashboard_refresh.yml +++ b/.github/workflows/dashboard_refresh.yml @@ -36,6 +36,8 @@ on: branches: [main] paths: - "registry.yaml" + - "decisions.yaml" + - "decisions/**" - "pulse/**" pull_request: diff --git a/CHECKIN.md b/CHECKIN.md index b5cbac9..8dd34bb 100644 --- a/CHECKIN.md +++ b/CHECKIN.md @@ -56,3 +56,25 @@ policy. Pulse scheduling metadata never changes measurement qualification or Heart readiness. Migration preserves original text and provenance; historical headers inside those texts are not a live queue. Resolve old Mind paths via `migration.yaml`; use the pinned source commit for unmigrated historical paths. + +## Record a key decision + +When the user says "record this decision" or explicitly flags a key campaign +decision, use `decisions/README.md` to produce a high-level campaign summary. +Read the relevant campaign/task history and cited project/Cortex evidence; +preserve hardware, dataset, model and cold/warm-start distinctions. Summarize +the evidence, alternatives, the human's choice and its implications, with +source links and limitations. Do not infer an accepted choice from timings, +a passing run, or an agent recommendation. If the user is still deciding, +prepare a draft and ask for the missing choice before adding a history entry. + +Write one canonical Markdown record in the campaign-leading organ and link it +from `decisions.yaml` on each relevant board. Do not categorize by repository +or dataset and do not duplicate the prose. Explicit direction to record a +stated decision authorizes the capture; ask only about genuinely missing facts. +Keep scientific sources authoritative and cite them. Preserve old decisions; +a changed choice receives a new dated record with supersession links. +Publish the owning record before adding its cross-dashboard link, verify the +GitHub destination, regenerate and check both affected boards through the +normal development workflow. Recording a decision does not authorize its +implementation or compute. Never turn an illustrative title into a decision. diff --git a/REFERENCE.md b/REFERENCE.md index 7878bcc..100572d 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -327,3 +327,12 @@ Rectangular, MGE, KNN, MGE Mass, Sersic, then other families alphabetically. Sersic retains one plain menu entry when only unclassified latent tools exist; this does not assign a backend. Known Sersic variants replace that fallback. + +## Decision History + +`decisions.yaml` indexes human-authored campaign summaries; `decisions/README.md` +defines capture, ownership, record format and supersession. The flat list follows +Results in HTML and Markdown. Both boards can link to one canonical record. +The reader validates IDs, dates, GitHub destinations and owned record metadata; +it never makes a scientific decision. Remote record existence is checked when +publishing the cross-dashboard link, not by offline rendering. diff --git a/dashboard.html b/dashboard.html index e3322ef..19c7e8f 100644 --- a/dashboard.html +++ b/dashboard.html @@ -645,7 +645,7 @@ Work on GitHub: - PyAutoPulse: https://github.com/PyAutoLabs/PyAutoPulse - autolens_profiling: https://github.com/PyAutoLabs/autolens_profiling
Last updated 2026-10-08 09:42:07 UTC
↻ Update

-

Active campaigns

CampaignStatusLinks
Setup baseline collection and scientific acceptance
1 task · next step

Specification merged as draft v1 (autolens_profiling#385, 2026-10-06; issue #384 closed). Six unknowns remain: no frozen revisions/env lock; no collection authorization/window; hardware/affinity identity unselected; each cell needs complete settings, input hashes, witness + tolerances; compile per-cell builder unavailable; proposed fp64/mixed coverage is not evidence of support. Human freezes the manifest before separately authorizing collection.

Reviewed 2026-10-07

Active tasks

Freeze the setup baseline specification, then collect and review evidence↗

needs-decision · high

Specification merged as draft v1 (autolens_profiling#385, 2026-10-06). Human resolves the six recorded unknowns and freezes the specification; collection requires separate compute authorization. The report prepares evidence for human scientific review and never promotes baselines. CPU production arrays use ral only.

needs-decision
Point-source image plane · CPU
1 task · next step

Phases 1–4 shipped; completion evidence merged (autolens_profiling#373, 2026-10-04). Closing the epic is the human's call. Owed leftovers (RAL cleanup, test move, register_model grad-zero prompt, CI smoke cells, constant_folding A/B, nopad deletion, quiet re-runs) are in the Mind draft point_source_cpu_campaign_owed_leftovers.md. RAL leftover folders not re-listed 2026-10-07 (unverified).

Reviewed 2026-10-07

Active tasks

active
Point-source image plane · A100
1 task · next step

Phase 0+1 shipped. Forward-mode gradient NaN (PyAutoLens#767) fixed by PyAutoLens#768 (merged 2026-10-04) and released in 2026.10.7.1 (correctness). Remaining human decision: authorize the autolens_inference image-plane fit measurement. Heart unit-test timing rose on the rewritten #768 test file; not a controlled cost comparison (gradient_cost_probe is the instrument).

Reviewed 2026-10-07

Active tasks

needs-decision
Point-source source plane
2 tasks · next step

Core complete; parked. Nautilus leaf autolens_inference#17 merged 2026-10-02; no gradient-sampler leaf yet, so Blackjax stays parked (admission not met). Warm-up task option (a) merged as autolens_profiling#374 (2026-10-04); imaging cells and a release sweep remain.

Reviewed 2026-10-07

Active tasks

Runtime cells' A100 `single_jit` includes the post-compile warm-up — source-plane 0.642 ms vs a steady 0.267 ms on the same node↗

active · low

Option (a) merged (autolens_profiling#374, 2026-10-04): full_pipeline_single_jit_median_ms (p10/p90) beside unchanged single_jit; GPU headline labelled "first block after compile". Imaging release-sweep cells not wired. No release sweep on main yet (results/ commits since 2026-10-04: #375 streaming rows, a README refactor), so the new field has no sweep rows.

parked
Cluster PointSolver robustness and performance
2 tasks · next step

Unchanged since 2026-09-27; nothing issued or claimed. Wiki still points at the removed Mind draft (now Pulse tasks/). Pick one bounded phase: observable-overflow invalid-result policy + CI witness, or a representative-data baseline cell.

Reviewed 2026-10-04

Active tasks

active
Interferometer likelihood and streaming
4 tasks · next step

Decision matrix complete: last cell merged (autolens_profiling#372, 2026-10-04; 1.59e-3 nat CPU/A100 gap accepted by the human). Streaming scaling phase 1 CPU rows merged (#375); 1e8 CPU and A100 rows need compute authorization. fnnls memo back-off released in 2026.10.7.1 (PyAutoArray#615); the #332 after-measurement is owed. Curvature preload and W~ FFT tasks unstarted.

Reviewed 2026-10-07

Active tasks

active
Linear-solver accuracy and cost
1 task · next step

Phase 3b MERGED 2026-10-08: autolens_profiling#396 (86cb1460), issue #395 closed, Mind record complete/2026/10/linear-solver-p3b-gpu-timing.md. A100 job 398249: pdip_raw per-eval min ms CPU 1.35/0.88/0.68 vs A100 3.95/0.58/0.19 at B=1/16/50 (SLaM distinct lanes), compile 0.3-1.2 s separate; jacobi 1.94x at A100 B=50 because one diverging lane pins the batch at the 50 cap. Timing is not admissibility; no pin moves. Phase 4a ISSUED 2026-10-08 (autolens_profiling#397, research): why jacobi batched vs unbatched trajectories differ on the A100 only (19/50 lanes; CPU identical) - Jacobi is the Mapper default, so this feeds a future GPU-default decision; wiki research page + decision table, one authorized A100 job. Next: /prm on the phase-4a PR when open.

Reviewed 2026-10-08

Active tasks

Linear-solver programme phase 3: GPU/vmap/A100 timing and parity rows for the solver corpus↗

active · medium

Phase 3 COMPLETE: 3a #394 (parity) and 3b #396 (vmap timing) merged; Mind records complete/2026/10/linear-solver-p3a-a100-parity.md and linear-solver-p3b-gpu-timing.md. Phase 4a research issued as #397 (Mind active/linear_solver_phase4_jacobi_a100_batched_divergence.md, worktree linear-solver-p4a-jacobi-a100-divergence, Opus executing). Task stays active until the 4a PR is merged and the human reads the decision table.

active
Production imaging and likelihood breakdown
3 tasks · next step

No new results since the pin. Over-sampling phase 1: add --instrument production presets to the JAX Delaunay cells (CPU smoke only); PyAutoArray#606 S/N over-sampling helper (in 2026.10.4.1) is new context. Post-certified Blocked-by is stale (resolved 2026-09-27).

Reviewed 2026-10-04

Active tasks

active
Measurement reliability and profiling tools
9 tasks · next step

Only timing_noise_audit is issued (autolens_profiling#362: open, 0 comments, unassigned, unchanged since 2026-10-02); start its audit phase via start_dev. New related Mind draft draft/bug/autolens_profiling/call_accounting_ci_timing_threshold.md (2026-10-06). Other tasks unchanged and unissued.

Reviewed 2026-10-07

Active tasks

Audit timing tests and profiling gates for measurement noise↗

ready · high

Issued as autolens_profiling#362 (open, 0 comments, unassigned, unchanged since 2026-10-02). Related new Mind draft draft/bug/autolens_profiling/call_accounting_ci_timing_threshold.md (2026-10-06). Start the read-and-classify audit phase via start_dev.

active
PyAutoFit profiling bootstrap
1 task · next step

Skeleton merged: autofit_profiling#1 (86c77345, 2026-10-07T21:17Z; AGENTS, hooks, hpc/sync, lint) under search-extensibility B1 (PyAutoMind#492). B1 docs legs PyAutoHeart#292 and .github#34 still open 2026-10-08. Porting unstarted; no producer registration until profiling-summary@2 is published (B4a).

Reviewed 2026-10-08

Active tasks

autofit_profiling: bootstrap the repo + general PyAutoFit profiling epic

ready · normal

Adopted by the search-extensibility epic (Mind draft/research/autofit/search_extensibility_epic.md); skeleton merged as autofit_profiling#1 (2026-10-07T21:17Z) under PyAutoMind#492 (B1). Next is B4a (search.fit breakdown exporter, Pulse fit row, epic-1 bottleneck table), then B4b (EP/graphical baseline port). No producer registration until profiling-summary@2 is published (B4a).

ready
Critical curves and evaluation grids
0 tasks · next step

Phase 3b cap fix shipped: PyAutoGalaxy#646 released in 2026.10.4.1 and autolens_workspace_test#343 merged 2026-10-04. Remaining per the wiki Next line: investigate seed coverage/path completion before cluster engine selection (human picks whether to scope it). Wiki page itself is stale (last edited 2026-10-02, still says #646 open).

Reviewed 2026-10-07

Active tasks

No open tasks.

needs-decision
Certified positive solver
0 tasks · next step

Parked by choice; wiki unchanged since ab1e4fd (2026-09-27). Its three library/workspace Mind drafts remain in Mind. Retain parked; read the project ledger before proposing new work.

Reviewed 2026-10-04

Active tasks

No open tasks.

parked

Open means tracked, not necessarily running. Full ledger ↗

Profiling Results

Decision History

No decisions recorded yet.

\n" ) diff --git a/pulse/cli.py b/pulse/cli.py index e107c35..eec3679 100644 --- a/pulse/cli.py +++ b/pulse/cli.py @@ -9,7 +9,7 @@ import sys from pathlib import Path -from pulse import ORGAN_ROOT, board, campaigns, ingest, registry +from pulse import ORGAN_ROOT, board, campaigns, decisions, ingest, registry from pulse import summary as summary_mod @@ -157,6 +157,12 @@ def _check_state(path: Path) -> list[str]: def cmd_check(args) -> int: problems = [] + try: + decision_rows = decisions.load() + except ValueError as exc: + print(f"FAIL decisions: {exc}") + return 1 + try: campaign_data = campaigns.load() except campaigns.CampaignError as exc: @@ -210,6 +216,12 @@ def cmd_check(args) -> int: problems.append("dashboard.md / dashboard.html missing") print("FAIL dashboard: dashboard.md / dashboard.html missing — run `pyauto-pulse board`") else: + if ( + decisions.marker(decision_rows) not in md.read_text() + or decisions.marker(decision_rows) not in page.read_text() + ): + problems.append("decision history stale — regenerate board") + print("FAIL decisions: dashboard does not match the decision index") expected = campaigns.marker(campaign_data) if expected not in md.read_text() or expected not in page.read_text(): problems.append("campaign dashboard stale — run pyauto-pulse board") diff --git a/pulse/decisions.py b/pulse/decisions.py new file mode 100644 index 0000000..0bf9dc0 --- /dev/null +++ b/pulse/decisions.py @@ -0,0 +1,106 @@ +"""Flat links to human-authored campaign decisions; never infer conclusions.""" + +import hashlib +import html +import json +import re +from datetime import date +from pathlib import Path +from urllib.parse import urlsplit + +import yaml + +from pulse import ORGAN_ROOT + +REPO = "PyAutoPulse" + + +def load(root: Path = ORGAN_ROOT) -> list[dict]: + """Validate the index and locally owned records; remote records stay links.""" + try: + document = yaml.safe_load((root / "decisions.yaml").read_text()) + except (OSError, yaml.YAMLError) as exc: + raise ValueError(f"Decision history: {exc}") from exc + if ( + not isinstance(document, dict) + or set(document) != {"decisions"} + or not isinstance(document["decisions"], list) + ): + raise ValueError("Decision history requires a decisions list") + seen = set() + rows = [] + for row in document["decisions"]: + if not isinstance(row, dict) or set(row) != {"id", "title", "date", "url"}: + raise ValueError("Decision requires id, title, date and url") + if any( + not isinstance(v, str) or not v.strip() or any(ord(c) < 32 for c in v) + for v in row.values() + ): + raise ValueError("Decision fields must be nonempty single-line strings; quote dates") + key = row["id"] + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", key) or key in seen: + raise ValueError(f"Invalid or duplicate decision id: {key}") + seen.add(key) + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", row["date"]): + raise ValueError("Decision date must be YYYY-MM-DD") + date.fromisoformat(row["date"]) + url = urlsplit(row["url"]) + match = re.fullmatch( + r"/PyAutoLabs/(PyAutoPulse|PyAutoInsight)/blob/main/decisions/([a-z0-9-]+)\.md", + url.path, + ) + if ( + url.scheme != "https" + or url.netloc != "github.com" + or url.query + or url.fragment + or not match + or match[2] != key + ): + raise ValueError( + "Decision URL must link to its canonical GitHub decisions/.md record" + ) + if match[1] == REPO: + path = root / "decisions" / f"{key}.md" + if not path.is_file(): + raise ValueError(f"Missing decision record: {path}") + content = path.read_text() + if ( + not content.startswith(f"# {row['title']}\n") + or f"Date: {row['date']}\n" not in content + ): + raise ValueError(f"Decision title/date differ from index: {key}") + rows.append(dict(row)) + return sorted(rows, key=lambda row: (row["date"], row["id"]), reverse=True) + + +def marker(rows): + digest = hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest() + return f"" + + +def render_html(rows=None): + rows = load() if rows is None else rows + links = "".join( + f'' + f"{html.escape(row['title'])}" + for row in rows + ) + return ( + marker(rows) + + '

Decision History

' + + (links or "

No decisions recorded yet.

") + + "
" + ) + + +def markdown(rows=None): + rows = load() if rows is None else rows + # HTML anchors also work in GitHub Markdown and safely escape arbitrary titles. + links = [ + f'- {html.escape(row["title"])}' + for row in rows + ] + return "\n".join( + [marker(rows), "## Decision History", "", *(links or ["No decisions recorded yet."]), ""] + ) diff --git a/tests/browser_fixture.py b/tests/browser_fixture.py index b17835b..9dd938a 100644 --- a/tests/browser_fixture.py +++ b/tests/browser_fixture.py @@ -5,6 +5,7 @@ import json import sys from pathlib import Path +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from pulse import board # noqa: E402 @@ -185,6 +186,18 @@ def write(destination): numba_row["evidence"] = numba["evidence"] readable.doc["records"].append(numba_row) (destination / "readable.html").write_text(board.render_html([readable])) + with patch( + "pulse.decisions.load", + return_value=[ + { + "id": "solver", + "title": "NNLS Solver Setup", + "date": "2026-10-08", + "url": "https://github.com/PyAutoLabs/PyAutoInsight/blob/main/decisions/solver.md", + } + ], + ): + (destination / "decision-history.html").write_text(board.render_html([readable])) menu = copy.deepcopy(readable) for model in ("mge_mass", "knn", "mge", "rectangular"): diff --git a/tests/browser_setup.cjs b/tests/browser_setup.cjs index efdbe41..e8dfe81 100644 --- a/tests/browser_setup.cjs +++ b/tests/browser_setup.cjs @@ -547,6 +547,17 @@ const { execFileSync } = require("node:child_process"); "implementation", ) === "numba", ); + await page.goto(base + "/decision-history.html"); + const history = page.locator("#decision-history"); + const historyDetails = history.locator("xpath=ancestor-or-self::details[1]"); + assert.equal(await historyDetails.getAttribute("open"), null); + await historyDetails.locator("summary").first().click(); + const decisionLink = history.getByRole("link", {name: "NNLS Solver Setup", exact: true}); + assert.equal(await decisionLink.getAttribute("target"), "_blank"); + assert.equal(await decisionLink.getAttribute("href"), "https://github.com/PyAutoLabs/PyAutoInsight/blob/main/decisions/solver.md"); + assert(await decisionLink.isVisible()); + await page.setViewportSize({width: 390, height: 844}); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); await page.goto(base + "/menu-order.html"); assert.deepEqual( await page.locator('[data-family="imaging"] .model-choice').allTextContents(), diff --git a/tests/test_cli.py b/tests/test_cli.py index 391999b..08231f9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -97,3 +97,27 @@ def test_board_from_a_local_file_is_labelled_local(argv, tmp_path, capsys): def test_unknown_instance_is_a_clean_error(argv, capsys): assert cli.main(argv("fetch", "--instance", "nope")) == 1 assert "no instance 'nope'" in capsys.readouterr().err + + +def test_decision_only_change_requires_regeneration(argv, web, monkeypatch, capsys): + from pulse import decisions + + web.publish(GH, SHA_A, PATH, fixture_doc("lens_summary_v1.json")) + monkeypatch.setattr(decisions, "load", lambda: []) + assert cli.main(argv("board")) == 0 + monkeypatch.setattr( + decisions, + "load", + lambda: [ + { + "id": "solver", + "title": "Solver choice", + "date": "2026-10-08", + "url": "https://github.com/PyAutoLabs/PyAutoInsight/blob/main/decisions/solver.md", + } + ], + ) + assert cli.main(argv("check", "--offline")) == 1 + assert "FAIL decisions" in capsys.readouterr().out + assert cli.main(argv("board", "--offline")) == 0 + assert cli.main(argv("check", "--offline")) == 0 diff --git a/tests/test_decisions.py b/tests/test_decisions.py new file mode 100644 index 0000000..5654bb3 --- /dev/null +++ b/tests/test_decisions.py @@ -0,0 +1,96 @@ +from copy import deepcopy + +import pytest +import yaml + +from pulse import board, decisions + + +def row(key="solver", day="2026-10-08", repo="PyAutoPulse"): + return { + "id": key, + "title": "NNLS & choice", + "date": day, + "url": f"https://github.com/PyAutoLabs/{repo}/blob/main/decisions/{key}.md", + } + + +def write(root, rows): + (root / "decisions.yaml").write_text(yaml.safe_dump({"decisions": rows})) + (root / "decisions").mkdir(exist_ok=True) + for item in rows: + (root / "decisions" / f"{item['id']}.md").write_text( + f"# {item['title']}\n\nDate: {item['date']}\n" + ) + + +def test_records_sorted_and_cross_organ_has_one_canonical_url(tmp_path): + rows = [row("old", "2026-09-01"), row("new", repo="PyAutoInsight")] + write(tmp_path, rows) + (tmp_path / "decisions/new.md").unlink() + loaded = decisions.load(tmp_path) + assert [r["id"] for r in loaded] == ["new", "old"] + html = decisions.render_html(loaded) + assert 'target="_blank" rel="noopener"' in html + assert "NNLS <solver> & choice" in html + assert "NNLS " not in html + assert loaded[0]["url"] in html + assert loaded[0]["url"] in decisions.markdown(loaded) + + +@pytest.mark.parametrize( + "mutation", + [ + {"url": "javascript:alert(1)"}, + {"url": "https://github.com.evil/PyAutoLabs/PyAutoPulse/blob/main/decisions/solver.md"}, + {"url": "https://github.com/PyAutoLabs/PyAutoPulse/blob/main/decisions/other.md"}, + {"date": "2026-02-30"}, + {"date": "2026-1-01"}, + {"id": "../escape"}, + {"title": "bad\nheading"}, + {"extra": "typo"}, + ], +) +def test_invalid_index_rejected(tmp_path, mutation): + entry = row() + entry.update(mutation) + (tmp_path / "decisions.yaml").write_text(yaml.safe_dump({"decisions": [entry]})) + with pytest.raises(ValueError): + decisions.load(tmp_path) + + +def test_duplicate_missing_and_mismatched_owned_records(tmp_path): + entry = row() + write(tmp_path, [entry, deepcopy(entry)]) + with pytest.raises(ValueError, match="duplicate"): + decisions.load(tmp_path) + write(tmp_path, [entry]) + (tmp_path / "decisions/solver.md").write_text("# Different\nDate: 2026-10-08\n") + with pytest.raises(ValueError, match="differ"): + decisions.load(tmp_path) + (tmp_path / "decisions/solver.md").unlink() + with pytest.raises(ValueError, match="Missing"): + decisions.load(tmp_path) + + +def test_board_disclosure_and_markdown_include_history(monkeypatch): + monkeypatch.setattr(decisions, "load", lambda: [row(repo="PyAutoInsight")]) + html = board.render_html([]) + assert 'href="#decision-history"' in html + assert 'id="decision-history"' in html + assert "NNLS <solver> & choice" in html + assert "Decision History" in board.render_markdown([]) + assert row(repo="PyAutoInsight")["url"] in board.render_markdown([]) + monkeypatch.setattr(decisions, "load", lambda: []) + assert "No decisions recorded yet." in board.render_html([]) + + +def test_cli_rejects_invalid_decision_index(monkeypatch, capsys): + from pulse import cli + + def invalid(): + raise ValueError("broken decision index") + + monkeypatch.setattr(decisions, "load", invalid) + assert cli.main(["check", "--offline"]) == 1 + assert "FAIL decisions: broken decision index" in capsys.readouterr().out