From 4629034f1dd13cc9a2d2e855b1026f37ee07f200 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 23 Oct 2026 15:14:09 +0100 Subject: [PATCH] feat: declare lifecycle setup fields, render setup_resolved provenance, fix doc drift (likelihood-lifecycle 0D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - summary.lifecycle_errors validates the optional additive record fields on every version's record path (v1, v2 catalogue, v3 shards): setup_mode in {auto, explicit} (else ": setup_mode must be auto or explicit"), setup_resolved (text-or-null backend/device/operator/precision/transformer, int-or-null threads), host_class and model_variant text, likelihood {dataset_type, variant}. Declarations only: no Pulse cross-check, no inferred resolution, no acceptance. - setup_browser: one "Setup: …" provenance line in each record disclosure. - Fixture: experiment-cold declares setup_mode auto with setup_resolved; tests for acceptance, the named refusal, malformed fields, v3 shards and rendering. - Docs: lifecycle row in the identity table, live pins lens@2 / fit@3, two live producers, full-fit wall-time validation routed to Pulse (walltime-validation@1, lens-walltime); one CHECKIN sentence. Closes #39 Co-Authored-By: Claude Fable 5.1 --- CHECKIN.md | 2 ++ README.md | 5 ++-- REFERENCE.md | 19 ++++++++++--- insight/setup_browser.py | 23 +++++++++++++++- insight/summary.py | 42 +++++++++++++++++++++++++++++ tests/fixtures/lens_summary_v2.json | 15 +++++++++++ tests/test_catalogue.py | 39 +++++++++++++++++++++++++++ tests/test_setup_browser.py | 15 +++++++++++ tests/test_sharded.py | 36 +++++++++++++++++++++++++ 9 files changed, 189 insertions(+), 7 deletions(-) diff --git a/CHECKIN.md b/CHECKIN.md index ab2b425..edca0b7 100644 --- a/CHECKIN.md +++ b/CHECKIN.md @@ -101,6 +101,8 @@ may have their own maintained contract. For mixed campaign maps, completed phases remain historical and are not reissued; deferred human gates survive. The EP map's profiling bootstrap/scoping references route to Pulse; library implementation phases route to Mind. Neither constitutes another organ queue. +Full-fit wall-time validation (`walltime-validation@1`, instance `lens-walltime`) +is read on the Pulse board, not here. Mind retains bounded implementation prompts, issue/PR lifecycle and repository claims. Reuse existing issues; a broad campaign map is never issued wholesale. diff --git a/README.md b/README.md index 42281d5..c04b3b9 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ bin/pyauto-insight check --offline bin/pyauto-insight census ``` -The first real producer is autolens_inference. A second-producer fixture tests -interoperability; it is not a claim of a second adopter. +Two live producers are registered: autolens_inference (`lens`) and +autofit_inference (`fit`). Test fixtures exercise generic reading only and are +never registered as adopters. ### Shared board presentation diff --git a/REFERENCE.md b/REFERENCE.md index 44336c4..9f42eae 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -10,8 +10,9 @@ There is no inference conductor and no new scientific execution engine. `registry.yaml` version `schema: 1` lists instances with `instance`, `repo`, `summary_path`, `supported_schema`, `dashboard_url`, `library_refs` and `cortex_project`, plus an optional `migrating_from` (below). Repository identity/location resolves from Mind's `repos.yaml`. -A source is registered once. A fixture demonstrates generic reading without being -registered as a real second adopter. Initial real producer: autolens_inference. +A source is registered once. Two live producers are registered: +autolens_inference (`lens`) and autofit_inference (`fit`). Fixtures demonstrate +generic reading and are never registered as adopters. The reader supports `schema: inference-summary`, integer `version: 1`, `2` or `3`. Required envelope keys: project, scope, generated_at, evidence_updated_at, @@ -65,8 +66,8 @@ experiments. Its measurements are test data, not scientific recommendations. The reader accepts both versions, but a registry row still pins **one exact version**. Deploy this reader first, then the project producer, then change the -registry pin with its consumer. The live registry remains `inference-summary@1` -in this phase. Rendering and scientific execution are unchanged. There is no +registry pin with its consumer. The live pins are `lens` at `inference-summary@2` +and `fit` at `inference-summary@3`. Rendering and scientific execution are unchanged. There is no automatic v1 conversion and no inferred baseline, start state or acceptance. ### Identity and ownership @@ -76,6 +77,7 @@ automatic v1 conversion and no inferred baseline, start state or acceptance. | Setup | `id`, `label`, `dataset_family`, `model_family`, `instrument`, `reference_record_id` | Stable navigable setup, e.g. imaging/delaunay/HST; IDs are independent of script paths. Multiple exact configurations can live under the family. | | Prepared problem | `id`, `setup_id`, `dataset_id`, `model_id`, `priors_id`, `stage`, `baseline_record_id`, `artifacts` | Exact scientific problem shared by baseline and experiments. Model and prior IDs identify frozen definitions, not display names. | | Run record | All v1 fields plus `setup_id`, `problem_id`, `experiment_protocol`, `initialization`, `environment`, `work` | A particular measurement with its own seed, sampler, hardware, revisions, diagnostics and clocks. | +| Run record lifecycle (optional, additive) | `setup_mode`, `setup_resolved`, `host_class`, `model_variant`, `likelihood` | Provenance of how the likelihood setup was chosen; declarations only, never identity, acceptance or ranking. `setup_mode` is `explicit` (the producer named the setup) or `auto` (the library chose it: phase 5 of the likelihood-lifecycle epic); any other value is refused. `setup_resolved` records what was used or chosen: text-or-null `backend`, `device`, `operator`, `precision`, `transformer` and integer-or-null `threads`. `host_class` is text provenance, never identity. `model_variant` is text; `likelihood` names `dataset_type` and `variant`. Absent is allowed; nothing is cross-checked against Pulse or inferred. | `instrument` and `reference_record_id` may be null with a sibling `_reason`. Prepared problem `dataset_id`, `model_id`, `priors_id`, `stage`, and @@ -309,6 +311,15 @@ Baseline manifests, archived results, comparison refusals and provenance remain available in disclosures. Unmapped records remain reachable from the project. Without JavaScript, setup content remains accessible as ordinary disclosures. Images, when declared, resolve at the captured producer revision. +A record that declares lifecycle fields shows one provenance line in its +disclosure, `Setup: · /// · + · `, with missing parts omitted. The line filters +and ranks nothing. + +Full-fit wall-time validation is not an Insight artefact. It is a PyAutoPulse +evidence axis (`walltime-validation@1`, Pulse registry instance `lens-walltime`), +produced by autolens_inference as `dashboard/walltime_validation.json`. Insight +neither hosts nor validates it; read it on the Pulse board. `sampler_candidates.json` is a manually reviewed public handoff from literature curation. Each entry records primary paper and official code URLs, review date, diff --git a/insight/setup_browser.py b/insight/setup_browser.py index 683b607..741dcff 100644 --- a/insight/setup_browser.py +++ b/insight/setup_browser.py @@ -75,6 +75,27 @@ def seconds(value): return f"{value:,.3g} s" if value < 60 else f"{value / 60:,.3g} min ({value:,.3g} s)" +def provenance(row): + """The record's declared setup as one line; empty when nothing is declared. + + Provenance only: the line shows what the producer declared (or, under + ``auto``, what the library chose) and never filters or ranks records. + """ + resolved = row.get("setup_resolved") + resolved = resolved if isinstance(resolved, dict) else {} + tuple_ = "/".join( + str(resolved[k]) + for k in ("backend", "device", "operator", "precision") + if resolved.get(k) is not None + ) + parts = [ + str(x) + for x in (row.get("setup_mode"), tuple_, row.get("host_class"), row.get("model_variant")) + if x + ] + return f'

Setup: {e(" · ".join(parts))}

' if parts else "" + + def record(snapshot, row): init = row.get("initialization", {}) env = row.get("environment", {}) @@ -90,7 +111,7 @@ def record(snapshot, row): "archived" if row["archived"] else row["execution"]["status"], ) ) - body = metrics( + body = provenance(row) + metrics( [ ("Sampling", seconds(timing["sampling_s"])), ("Fit call", seconds(timing["total_s"])), diff --git a/insight/summary.py b/insight/summary.py index 44ba6a4..13a8cea 100644 --- a/insight/summary.py +++ b/insight/summary.py @@ -72,6 +72,47 @@ def supported(doc, wanted=None): return None +#: ``setup_mode`` values: ``explicit`` = the producer named the setup; +#: ``auto`` = the library chose it, and ``setup_resolved`` records what it chose. +SETUP_MODES = ("auto", "explicit") +#: Text-or-null members of a record's ``setup_resolved`` object. +SETUP_RESOLVED_TEXT = ("backend", "device", "operator", "precision", "transformer") + + +def lifecycle_errors(r, rid): + """Optional additive lifecycle fields, validated as declarations only. + + Absent is allowed (pre-lifecycle producers). Nothing is cross-checked against + Pulse, an ``auto`` resolution is never inferred, and none of these fields + feeds acceptance, ranking or setup selection. ``host_class`` is provenance, + never identity. + """ + errors = [] + if "setup_mode" in r and r["setup_mode"] not in SETUP_MODES: + errors.append(f"{rid}: setup_mode must be auto or explicit") + if "setup_resolved" in r: + resolved = r["setup_resolved"] + if not isinstance(resolved, dict): + errors.append(f"{rid}: setup_resolved must be an object") + else: + for key in SETUP_RESOLVED_TEXT: + if resolved.get(key) is not None and not _text(resolved[key]): + errors.append(f"{rid}: setup_resolved.{key} must be text or null") + threads = resolved.get("threads") + if threads is not None and (type(threads) is not int or threads < 0): + errors.append(f"{rid}: setup_resolved.threads must be integer or null") + for key in ("host_class", "model_variant"): + if r.get(key) is not None and not _text(r[key]): + errors.append(f"{rid}: {key} must be text or null") + if r.get("likelihood") is not None: + likelihood = r["likelihood"] + if not isinstance(likelihood, dict) or not all( + _text(likelihood.get(k)) for k in ("dataset_type", "variant") + ): + errors.append(f"{rid}: likelihood must name dataset_type and variant as text") + return errors + + def validate(doc): if not isinstance(doc, dict): return ["summary is not an object"] @@ -284,6 +325,7 @@ def validate(doc): or not isinstance(diagnostics.get("values"), dict) ): errors.append(f"{rid}: diagnostics require status and values") + errors += lifecycle_errors(r, rid) by_id = {r["id"]: r for r in recs if isinstance(r, dict) and _text(r.get("id"))} for r in by_id.values(): parent = r.get("parent_run_id") diff --git a/tests/fixtures/lens_summary_v2.json b/tests/fixtures/lens_summary_v2.json index 5c4ae41..591c3c8 100644 --- a/tests/fixtures/lens_summary_v2.json +++ b/tests/fixtures/lens_summary_v2.json @@ -363,6 +363,21 @@ "retained_samples": "Stored posterior samples after warmup", "effective_sample_size": "Sampler-reported posterior ESS" } + }, + "setup_mode": "auto", + "setup_resolved": { + "backend": "jax", + "device": "cpu", + "operator": "dense", + "precision": "fp64", + "transformer": "na", + "threads": null + }, + "host_class": "local_laptop", + "model_variant": "default", + "likelihood": { + "dataset_type": "imaging", + "variant": "delaunay" } }, { diff --git a/tests/test_catalogue.py b/tests/test_catalogue.py index 09924b0..8eeb8ce 100644 --- a/tests/test_catalogue.py +++ b/tests/test_catalogue.py @@ -271,3 +271,42 @@ def test_v2_empty_and_unmapped_legacy_records_are_valid(): assert summary.classify(doc) == ("ok", []) doc["records"] = [] assert summary.classify(doc) == ("ok", []) + + +def test_lifecycle_setup_fields_are_accepted_as_declarations(): + doc = document() + auto = next(r for r in doc["records"] if r["id"] == "experiment-cold") + assert auto["setup_mode"] == "auto" and auto["setup_resolved"]["backend"] == "jax" + assert summary.classify(doc) == ("ok", []) + # Absent fields stay valid: a pre-lifecycle record declares none of them. + assert "setup_mode" not in doc["records"][0] + # Declarations never become acceptance. + assert summary.qualified_records(doc) == 0 + + +@pytest.mark.parametrize("mode", ["automatic", "", None, 1]) +def test_setup_mode_outside_auto_and_explicit_is_refused_by_name(mode): + doc = document() + doc["records"][2]["setup_mode"] = mode + status, errors = summary.classify(doc) + assert status == "invalid" + assert "experiment-cold: setup_mode must be auto or explicit" in errors + + +@pytest.mark.parametrize( + "key, value, needle", + [ + ("setup_resolved", "jax/cpu", "setup_resolved must be an object"), + ("setup_resolved", {"backend": 3}, "setup_resolved.backend must be text or null"), + ("setup_resolved", {"threads": "8"}, "setup_resolved.threads must be integer or null"), + ("setup_resolved", {"threads": True}, "setup_resolved.threads must be integer or null"), + ("host_class", "", "host_class must be text or null"), + ("model_variant", 7, "model_variant must be text or null"), + ("likelihood", {"dataset_type": "imaging"}, "likelihood must name dataset_type"), + ], +) +def test_malformed_lifecycle_fields_are_refused(key, value, needle): + doc = document() + doc["records"][2][key] = value + status, errors = summary.classify(doc) + assert status == "invalid" and any(needle in e for e in errors), errors diff --git a/tests/test_setup_browser.py b/tests/test_setup_browser.py index e0ad59c..955c90d 100644 --- a/tests/test_setup_browser.py +++ b/tests/test_setup_browser.py @@ -142,3 +142,18 @@ def test_home_keeps_actionable_warnings_outside_collapsed_provenance(registry_fi assert "past its declared freshness" in prefix assert "Integrity:" not in prefix assert '

' not in detail + + +def test_record_disclosure_shows_declared_setup_as_provenance(registry_file, fake_mind): + snapshot = view(registry_file, fake_mind, "lens_summary_v2.json") + _, pages = setup_browser.render(snapshot) + assert "Setup: auto · jax/cpu/dense/fp64 · local_laptop · default" in pages + # A record without lifecycle fields renders no provenance line. + assert setup_browser.provenance(snapshot.doc["records"][0]) == "" + + +def test_provenance_line_omits_missing_parts(): + row = {"setup_mode": "explicit", "setup_resolved": {"backend": "numba", "device": None}} + assert setup_browser.provenance(row) == ( + '

Setup: explicit · numba

' + ) diff --git a/tests/test_sharded.py b/tests/test_sharded.py index e9d80fd..fa50dbe 100644 --- a/tests/test_sharded.py +++ b/tests/test_sharded.py @@ -84,6 +84,42 @@ def test_assembled_v3_validates_like_v1(): assert summary.classify(index, ("inference-summary", 3))[0] == "invalid" +def test_v3_shard_records_accept_lifecycle_fields_and_refuse_a_bad_mode(): + def declare(mode): + def tweak(index, files): + path = "records/proto@1/leg0.json" + shard = json.loads(files[path]) + shard["records"][0].update( + setup_mode=mode, + setup_resolved={ + "backend": "jax", + "device": "gpu", + "operator": "dense", + "precision": "fp64", + "transformer": "na", + "threads": None, + }, + host_class="hpc_a100", + model_variant="light_fixed", + likelihood={"dataset_type": "imaging", "variant": "rectangular"}, + ) + body = json.dumps(shard, sort_keys=True).encode() + files[path] = body + index["record_shards"][0]["sha256"] = hashlib.sha256(body).hexdigest() + + return tweak + + index, files = make_v3(tweak=declare("auto")) + doc, errors = summary.load_sharded(index, files.__getitem__) + assert errors == [] + assert summary.classify(doc, ("inference-summary", 3)) == ("ok", []) + index, files = make_v3(tweak=declare("chosen")) + doc, errors = summary.load_sharded(index, files.__getitem__) + status, errors = summary.classify(doc, ("inference-summary", 3)) + assert status == "invalid" + assert f"{doc['records'][0]['id']}: setup_mode must be auto or explicit" in errors + + @pytest.mark.parametrize( "tweak, needle", [