From 43d0201c69e438f99f8a0b50081227620bb998d1 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sun, 11 Oct 2026 21:29:24 +0100 Subject: [PATCH] feat: publish Broca shared state from saved evidence --- .github/workflows/pages.yml | 7 +- .gitignore | 1 + README.md | 19 +++++ assistants.json | 2 + broca/__main__.py | 12 +++ broca/board.py | 11 ++- broca/collect.py | 2 +- broca/state.py | 97 ++++++++++++++++++++++ docs/state-feed.md | 59 +++++++++++++ tests/test_pages_workflow.py | 3 + tests/test_state.py | 155 +++++++++++++++++++++++++++++++++++ 11 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 broca/state.py create mode 100644 docs/state-feed.md create mode 100644 tests/test_state.py diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e82d9cf..6b5ac33 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -32,7 +32,12 @@ jobs: - name: Validate saved evidence run: python -m broca --root PyAutoBroca check - name: Render dashboard without recollecting evidence - run: python -m broca --root PyAutoBroca render --brain PyAutoBrain --output site/index.html + run: >- + python -m broca --root PyAutoBroca render --brain PyAutoBrain + --output site/index.html --state-output site/state.json + --repository "$GITHUB_REPOSITORY" + - name: Validate shared organ feed + run: python PyAutoBrain/board/_state.py site/state.json - name: Upload site only uses: actions/upload-pages-artifact@v3 with: diff --git a/.gitignore b/.gitignore index 98382d5..a8cd602 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .venv/ preview/ *.egg-info/ +site/ diff --git a/README.md b/README.md index 276643a..c486f7b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,25 @@ from response quality. `--import-history` imports existing runner meta/score summaries without changing source files. Missing or conflicting evidence is reported, not discarded. A failed/partial refresh shows unknown freshness. +## Scientist state feed + +The Pages artifact includes `index.html` and the shared `state.json`. To generate +both locally without collecting evidence: + +```bash +python -m broca render --brain /path/to/PyAutoBrain --output site/index.html \ + --state-output site/state.json --repository YourOwner/YourCommunicationRepo \ + --pages-url https://research.example/guides/ +python /path/to/PyAutoBrain/board/_state.py site/state.json +``` + +Supply the canonical `owner/repository` identity from your Mind map or publishing +context; Pages uses `GITHUB_REPOSITORY`. No second identity map is stored here. +The board URL comes from `assistants.json`'s `pages_url`, or the explicit override. +An empty `assistants: []` supports a guides/workspaces-only instance. HTML-only +rendering remains supported. See [feed semantics](docs/state-feed.md) for clocks, +statuses and the absence of collection/evaluation claims. + ## Storage and comparisons `records/.json` is an immutable version-1 evaluation record; diff --git a/assistants.json b/assistants.json index f872013..d68e6d2 100644 --- a/assistants.json +++ b/assistants.json @@ -1,6 +1,8 @@ { "schema_version": 1, "board_base_url": "https://pyautolabs.github.io", + "pages_url": "https://pyautolabs.github.io/PyAutoBroca/", + "collection_max_age_days": 30, "assistants": ["autocti_assistant", "autofit_assistant", "autogalaxy_assistant", "autolens_assistant"], "evidence_max_age_days": 30 } diff --git a/broca/__main__.py b/broca/__main__.py index 12658d5..b573f06 100644 --- a/broca/__main__.py +++ b/broca/__main__.py @@ -23,6 +23,9 @@ def main(argv=None): board = sub.add_parser("render") board.add_argument("--brain", type=Path, required=True) board.add_argument("--output", type=Path, default=Path("dashboard.html")) + board.add_argument("--state-output", type=Path, help="also write the shared organ feed") + board.add_argument("--repository", help="canonical owner/repository from the publishing context") + board.add_argument("--pages-url", help="override the configured public board directory") args = parser.parse_args(argv) try: names = config(args.root)["assistants"] @@ -41,9 +44,18 @@ def main(argv=None): return 0 if result["refreshed_at"] else 1 else: from .board import render + state = None + if args.state_output: + if args.state_output.resolve() == args.output.resolve(): + raise ValueError("HTML and state output paths must differ") + from .state import build + state = build(args.root, args.brain, args.repository, args.pages_url) page = render(args.root, args.brain) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(page) + if state is not None: + args.state_output.parent.mkdir(parents=True, exist_ok=True) + args.state_output.write_text(json.dumps(state, indent=2) + "\n") print(args.output) except (ValueError, OSError, KeyError, TypeError) as exc: print(f"Broca: {exc}", file=sys.stderr) diff --git a/broca/board.py b/broca/board.py index 1e09097..bc14c6f 100644 --- a/broca/board.py +++ b/broca/board.py @@ -34,6 +34,13 @@ def evidence_state(record, row, max_age, today=None): return "Recorded pass" if record["status"] == "passed" else "Needs interpretation" +def latest_benchmark(runs): + """Day-precision ties retain the most conservative recorded outcome.""" + latest_day = max((r["observed_at"][:10] for r in runs), default=None) + return max((r for r in runs if r["observed_at"][:10] == latest_day), + key=lambda r: {"passed": 0, "unscored": 1, "failed": 2}[r["status"]], default=None) + + def render(root, brain): theme = theme_from(brain) cfg = config(root) @@ -52,9 +59,7 @@ def render(root, brain): key=lambda r: (r["observed_at"], r["run_id"])) # Date-only imports cannot establish an order within a day. Show the # most conservative outcome from the latest day, not a random run ID. - latest_day = max((r["observed_at"][:10] for r in runs), default=None) - latest = max((r for r in runs if r["observed_at"][:10] == latest_day), - key=lambda r: {"passed": 0, "unscored": 1, "failed": 2}[r["status"]], default=None) + latest = latest_benchmark(runs) state = evidence_state(latest, row, cfg["evidence_max_age_days"]) collected = row.get("status", "unavailable") errors = row.get("errors", []) diff --git a/broca/collect.py b/broca/collect.py index 0acd124..44ac4d2 100644 --- a/broca/collect.py +++ b/broca/collect.py @@ -23,7 +23,7 @@ def git(repo, *args): def config(root): data = json.loads((Path(root) / "assistants.json").read_text()) names = data.get("assistants", []) - if data.get("schema_version") != 1 or not names or len(names) != len(set(names)): + if data.get("schema_version") != 1 or not isinstance(names, list) or len(names) != len(set(names)): raise ValueError("invalid assistant configuration") if any(not isinstance(n, str) or not re.fullmatch(r"[A-Za-z0-9_]+", n) for n in names): raise ValueError("invalid assistant name") diff --git a/broca/state.py b/broca/state.py new file mode 100644 index 0000000..08d35c1 --- /dev/null +++ b/broca/state.py @@ -0,0 +1,97 @@ +"""Broca's shared feed: saved owner evidence, never a new collection or run.""" +from datetime import datetime, timedelta, timezone +import importlib.util +import json +from pathlib import Path +import re +from urllib.parse import urlsplit + +from .board import evidence_state, latest_benchmark +from .collect import config, load_snapshot +from .records import load_records + +UNKNOWN_TIME = "1970-01-01T00:00:00Z" # v1 requires a timestamp even when no receipt exists. + + +def shared_contract(brain): + spec = importlib.util.spec_from_file_location("broca_shared_state", Path(brain) / "board/_state.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def pages_directory(value): + if not isinstance(value, str): + raise ValueError("configure pages_url or supply --pages-url") + url = urlsplit(value) + if (url.scheme != "https" or not url.hostname or url.username or url.password + or url.query or url.fragment or "\\" in value or any(c.isspace() for c in value)): + raise ValueError("pages_url must be an explicit public HTTPS directory") + return value.rstrip("/") + "/" + + +def build(root, brain, repository, pages_url=None, *, now=None): + """Repository comes from canonical publishing context, not the checkout name.""" + if not isinstance(repository, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("--repository must be the canonical owner/repository identity") + cfg = config(root) + pages = pages_directory(pages_url or cfg.get("pages_url")) + max_age = cfg.get("collection_max_age_days", 30) + if type(max_age) is not int or max_age < 1: + raise ValueError("collection_max_age_days must be a positive integer") + contract = shared_contract(brain) + snapshot = load_snapshot(root) + records = load_records(root, cfg["assistants"]) + now = now or datetime.now(timezone.utc) + attempted = snapshot.get("attempted_at") + updated = attempted or UNKNOWN_TIME + if not contract._is_utc_iso(updated): + raise ValueError("collection attempt must have a full UTC timestamp") + expires = (datetime.fromisoformat(updated.replace("Z", "+00:00")) + timedelta(days=max_age)) if attempted else None + valid_until = expires + complete = snapshot.get("refreshed_at") is not None + items = [{"severity": "info" if complete else "yellow", "text": + ("Collection: " + snapshot["refreshed_at"]) if complete else + ("Collection incomplete; attempt " + attempted if attempted else "Collection unavailable; timestamp unknown"), + "url": pages + "#maintenance"}] + catalogue_path = Path(root) / "resources.json" + if catalogue_path.exists(): + catalogue = json.loads(catalogue_path.read_text()) + if not isinstance(catalogue, dict) or catalogue.get("schema_version") != 1 or not isinstance(catalogue.get("resources"), list): + raise ValueError("invalid public resource catalogue") + communication = {"workspaces", "tutorials", "documentation"} + count = 0 + for resource in catalogue["resources"]: + if not isinstance(resource, dict) or not isinstance(resource.get("relations"), list) or any(not isinstance(k, str) for k in resource["relations"]): + raise ValueError("invalid public resource relations") + if communication.intersection(resource["relations"]) and "assistants" not in resource["relations"]: + count += 1 + text = f"Guides & Workspaces: {count} resources (inventory)" + else: + text = "Guides & Workspaces: inventory unavailable" + items.append({"severity": "info", "text": text, "url": pages + "#communication"}) + rows = {row["assistant"]: row for row in snapshot["assistants"]} + states = [] + for name in cfg["assistants"]: + latest = latest_benchmark([r for r in records if r["assistant"] == name and r["kind"] == "benchmark"]) + state = evidence_state(latest, rows.get(name, {}), cfg["evidence_max_age_days"], today=now.date()) + states.append(state) + if state == "Recorded pass": + # evidence_state uses whole UTC calendar days: age == max is still + # current; it becomes outdated at the next midnight. A newer + # collection must not extend a passing benchmark's validity. + deadline = (datetime.fromisoformat(latest["observed_at"][:10]).replace(tzinfo=timezone.utc) + + timedelta(days=cfg["evidence_max_age_days"] + 1)) + valid_until = min(valid_until, deadline) if valid_until else deadline + severity = "red" if state == "Recorded failure" else "info" if state in {"Recorded pass", "Never evaluated"} else "yellow" + observed = f" · observed {latest['observed_at']}" if latest else "" + items.append({"severity": severity, "text": f"{name}: {state}{observed}", "url": pages + "#assistants"}) + stale = expires is not None and now > expires + if stale: + items.append({"severity": "yellow", "text": "Collection evidence outdated", "url": pages + "#maintenance"}) + status = ("red" if "Recorded failure" in states else "stale" if stale else + "yellow" if attempted and (not complete or any(i["severity"] == "yellow" for i in items)) else + "green" if complete and states and all(s == "Recorded pass" for s in states) else "grey") + return contract.build_state("broca", repository.split("/")[1], status, + "Guides & Workspaces · assistant evidence", updated, pages, items, + valid_until=valid_until.isoformat() if valid_until else None) diff --git a/docs/state-feed.md b/docs/state-feed.md new file mode 100644 index 0000000..1d073a9 --- /dev/null +++ b/docs/state-feed.md @@ -0,0 +1,59 @@ +# Broca state feed + +`render --state-output` builds Brain's shared version-1 contract using the selected +Brain checkout's `board/_state.py::build_state`. Scientist's collector and +`connect_organ.py` consume this contract without Broca-specific parsing. Identity +comes from explicit `--repository owner/repository`; `repo` in the feed is the +repository basename required by that contract. `pages_url` is independently +configured and must be an HTTPS directory. Declared identity is not access proof. + +The artifact contains only rendered HTML and JSON. It never includes raw receipts, +assistant transcripts, project source or the whole checkout. Rendering does not +collect, evaluate, publish by itself, or rewrite the append-only evidence store. + +## Clocks + +- `updated` is the saved latest collection **attempt**, whether complete or + incomplete. The collection row distinguishes the successful `refreshed_at` + timestamp from a failed/incomplete attempt. +- With no receipt, v1's required timestamp uses `1970-01-01T00:00:00Z` as an + explicit **unknown sentinel**, not an observation. The row states timestamp + unknown and the feed cannot be green. No `valid_until` is emitted. +- `valid_until` is bounded by the attempt timestamp plus + `collection_max_age_days` (default 30), and by the earliest expiry of any + currently passing benchmark. Evaluation expiry uses the same UTC calendar-day + rule as the dashboard: midnight after `evidence_max_age_days` whole days. A + newer collection cannot extend a passing benchmark's validity. The collection + policy remains separate from the evaluation-age policy. +- Each assistant row retains the latest benchmark's original observation date. + A new collection cannot freshen an old evaluation. Rendering never changes any + of these dates. No inventory timestamp or quality measurement is inferred for + the undated guides/workspaces catalogue. + +## Status meaning + +The same conservative latest-day benchmark selection and `evidence_state` +classification serve HTML and JSON. Maintenance/inventory records are not +benchmark passes. Same-day failure wins over pass because day-only timestamps +cannot establish ordering. + +The aggregate reports recorded failure as red; otherwise expired collection is +stale, incomplete collection or evidence needing interpretation is yellow, and +all configured assistants having current recorded passes after complete collection +is green. Missing collection, no assistants, or only never-evaluated assistants +is grey. Items preserve the distinct reasons even when one aggregate status +takes priority. Green describes recorded assistant benchmarks only; it does not +certify all communication resources or scientific correctness. + +Guides, HowTo tutorials and workspaces remain an informational inventory count, +separate from assistant evaluation rows. Their projects own source and meanings. +Ears listening, Memory knowledge, Cortex science, Heart readiness and Hands +release responsibilities are unchanged. The feed contains no executable actions +and does not create a new task ledger. + +Malformed/digest-mismatched current receipts, invalid records, invalid catalogue +shapes and invalid publishing configuration fail rendering. The existing Pages +check also validates historical receipts. The job validates the generated feed +before uploading the site; deployment runs only on main. Validate PR artifacts +before merge, then verify the actual HTTPS endpoint after successful deployment +before claiming a live Scientist connection. diff --git a/tests/test_pages_workflow.py b/tests/test_pages_workflow.py index 01b3262..da00ec3 100644 --- a/tests/test_pages_workflow.py +++ b/tests/test_pages_workflow.py @@ -13,6 +13,9 @@ def test_pages_publishes_only_rendered_snapshot_with_guarded_deployment(): commands = '\n'.join(step.get('run', '') for step in build['steps']) assert 'broca --root PyAutoBroca check' in commands assert 'render --brain PyAutoBrain --output site/index.html' in commands + assert '--state-output site/state.json' in commands + assert '--repository "$GITHUB_REPOSITORY"' in commands + assert 'python PyAutoBrain/board/_state.py site/state.json' in commands assert 'refresh' not in commands and '--pilot' not in commands upload = next(step for step in build['steps'] if step.get('uses', '').startswith('actions/upload-pages-artifact@')) assert upload['with']['path'] == 'site' diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..ce32220 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,155 @@ +"""Shared feed and site artifact preserve the meaning and age of owner evidence.""" +from datetime import datetime, timezone +import json +from pathlib import Path + +import pytest + +from broca.__main__ import main +from broca.records import digest, ingest +from broca.state import build, shared_contract, UNKNOWN_TIME + +BRAIN = Path(__file__).resolve().parents[2] / "PyAutoBrain" +NOW = datetime(2026, 10, 11, tzinfo=timezone.utc) + + +@pytest.fixture +def root(tmp_path): + (tmp_path / "assistants.json").write_text(json.dumps({"schema_version": 1, + "assistants": ["sample_assistant"], "evidence_max_age_days": 30, + "collection_max_age_days": 5, "pages_url": "https://research.example/guides/"})) + (tmp_path / "CHECKIN.md").write_text("Review existing evidence.") + (tmp_path / "resources.json").write_text(json.dumps({"schema_version": 1, + "resources": [{"id": "guide", "name": "Guide", "relations": ["documentation"], + "links": [], "projects": []}]})) + return tmp_path + + +def receipt(root, when="2026-10-10T00:00:00Z", complete=True): + snapshot = {"schema_version": 1, "attempted_at": when, + "refreshed_at": when if complete else None, + "assistants": [{"assistant": "sample_assistant", "status": "collected" if complete else "unavailable", + "revision": "a" * 40, "errors": []}]} + folder = root / "receipts"; folder.mkdir(exist_ok=True) + key = digest(snapshot) + (folder / (key + ".json")).write_text(json.dumps(snapshot)) + (root / "latest.json").write_text(json.dumps({"receipt": key})) + return folder / (key + ".json") + + +def benchmark(root, status="passed", day="2026-10-10", run_id="sample"): + record = {"schema_version": 1, "run_id": run_id, "assistant": "sample_assistant", + "kind": "benchmark", "observed_at": day, "date_precision": "day", + "assistant_sha": "a" * 40, "benchmark": {"id": "quality", "version": "1", "sha256": "b" * 64}, + "model": "fixture", "harness": "fixture", "environment": {}, "status": status, + "metrics": {}, "reason": "Saved outcome", "source": {"revision": "a" * 40, + "path": "benchmarks/sample.json", "sha256": "c" * 64}} + ingest(root, record, ["sample_assistant"]) + + +def feed(root): + return build(root, BRAIN, "Research/Communication", now=NOW) + + +def test_missing_collection_is_unknown_not_newly_measured(root): + state = feed(root) + assert shared_contract(BRAIN).validate_state(state) == [] + assert state["repo"] == "Communication" and state["organ"] == "broca" + assert state["updated"] == UNKNOWN_TIME and state["status"] == "grey" + assert "valid_until" not in state + assert any("timestamp unknown" in row["text"] for row in state["items"]) + assert any("1 resources (inventory)" in row["text"] for row in state["items"]) + + +def test_inventory_without_assistants_is_supported(root): + path = root / "assistants.json"; cfg = json.loads(path.read_text()); cfg["assistants"] = [] + path.write_text(json.dumps(cfg)) + state = feed(root) + assert state["status"] == "grey" + assert all("Recorded pass" not in i["text"] for i in state["items"]) + assert main(["--root", str(root), "check"]) == 0 + + +def test_collection_and_evaluation_clocks_stay_separate(root): + receipt(root) + benchmark(root, day="2026-01-01") + state = feed(root) + assert state["updated"] == "2026-10-10T00:00:00Z" + assert state["valid_until"] == "2026-10-15T00:00:00+00:00" + assert state["status"] == "yellow" + assert any("Evidence outdated" in row["text"] and "2026-01-01" in row["text"] for row in state["items"]) + assert feed(root) == state + + +def test_old_collection_and_missing_sources_are_not_healthy(root): + receipt(root, when="2026-09-01T00:00:00Z") + assert feed(root)["status"] == "stale" + receipt(root, complete=False) + state = feed(root) + assert state["status"] == "yellow" + assert state["updated"] == "2026-10-10T00:00:00Z" + assert "Collection incomplete" in state["items"][0]["text"] + + +def test_new_collection_cannot_extend_a_passing_benchmark_deadline(root): + receipt(root) + benchmark(root, day="2026-09-12") + state = feed(root) + assert state["status"] == "green" + assert state["updated"] == "2026-10-10T00:00:00Z" + assert state["valid_until"] == "2026-10-13T00:00:00+00:00" + later = build(root, BRAIN, "Research/Communication", + now=datetime(2026, 10, 13, tzinfo=timezone.utc)) + assert later["status"] == "yellow" + assert any("Evidence outdated" in row["text"] for row in later["items"]) + + +def test_recorded_failures_survive_same_day_pass_ties(root): + receipt(root) + benchmark(root, status="passed", run_id="pass") + assert feed(root)["status"] == "green" + benchmark(root, status="failed", run_id="fail") + assert feed(root)["status"] == "red" + + +def test_tampered_receipt_and_bad_catalogue_fail_closed(root): + path = receipt(root); path.write_text(path.read_text() + " ") # formatting is immaterial + data = json.loads(path.read_text()); data["assistants"][0]["revision"] = "b" * 40 + path.write_text(json.dumps(data)) + with pytest.raises(ValueError, match="digest mismatch"): + feed(root) + receipt(root) + (root / "resources.json").write_text('{"schema_version": 1, "resources": [42]}') + with pytest.raises(ValueError, match="resource relations"): + feed(root) + + +@pytest.mark.parametrize("identity,url", [("invalid", "https://research.example/"), + ("Research/Communication", "http://research.example/"), + ("Research/Communication", "https://user:password@research.example/")]) +def test_identity_and_location_are_explicit(root, identity, url): + with pytest.raises(ValueError): + build(root, BRAIN, identity, url, now=NOW) + + +def test_cli_site_artifact_contains_only_html_and_feed(root, tmp_path): + receipt(root) + before = {str(p.relative_to(root)): p.read_bytes() for p in root.rglob('*') if p.is_file()} + site = tmp_path / "site" + args = ["--root", str(root), "render", "--brain", str(BRAIN), "--output", str(site / "index.html"), + "--state-output", str(site / "state.json"), "--repository", "Research/RenamedGuides", + "--pages-url", "https://research.example/custom/"] + assert main(args) == 0 + assert sorted(p.name for p in site.iterdir()) == ["index.html", "state.json"] + state = json.loads((site / "state.json").read_text()) + assert state["repo"] == "RenamedGuides" and state["pages_url"] == "https://research.example/custom/" + assert shared_contract(BRAIN).validate_state(state) == [] + assert all((root / p).read_bytes() == contents for p, contents in before.items()) + assert "Guides & Workspaces" in (site / "index.html").read_text() + + +def test_invalid_feed_configuration_writes_no_artifact(root, tmp_path): + site = tmp_path / "site" + assert main(["--root", str(root), "render", "--brain", str(BRAIN), + "--output", str(site / "index.html"), "--state-output", str(site / "state.json")]) == 2 + assert not site.exists()