feat(eeg): add fail-closed structured-state shadow artifacts - #35
Conversation
Serializes offline encoder probabilities into a replayable shadow-state
contract. The bridge is downstream of an offline encoder artifact and stores no
waveform values, dialogue content, task labels, or policy actions.
Every record and its manifest carry the same status block, enforced on write
*and* re-verified on read:
status: insufficient_evidence
science_status: pipeline_only
shadow_only: true
live_control: false
promotion_status: not_eligible
The reader re-derives confidence, entropy, and argmax from the stored
probabilities rather than trusting them, so a tampered record is rejected even
when its manifest hash has been recomputed to match.
Runtime chain is numpy + stdlib only — no application-runtime import, no model
download, and no model execution.
Prerequisite for #33, whose noninterference test replays these artifacts.
Publication goes through a same-directory rename so an interrupted run cannot
leave a truncated artifact over a previously valid one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review — two changes before mergeThe module is coherent, scoped correctly, and the new numerical/status tests are valuable. I am holding the merge for two fail-closed contract gaps. 1. Replay does not yet re-verify the complete provenance contract
Today a record can have its encoder/source provenance changed, the manifest's Suggested tests:
2. Publication is atomic per file, not for the state/manifest pairThe state file is replaced before the manifest. A crash or manifest-write failure in between can replace a previously valid state file while leaving the old manifest, losing the prior valid pair. The current rejected-republication test fails during validation before either publish and does not exercise this seam. Decision on the open overwrite question:
Minimal safe contract:
Please add a test that a valid but different republication is refused and preserves both prior files byte-for-byte, plus a test for the interrupted-first-publication repair path. Once these pass with the full 46-test EEG suite, #35 is ready to merge. No change is requested to the shadow-only/runtime-authority boundary. |
Addresses both review blockers on #35. The module's read and publication contracts were weaker than its description claimed. Replay now binds records to their manifest ------------------------------------------ `records_sha256` only proves the records are the ones the manifest was written for — not that they agree with it. After recomputing that hash, a record could still claim a different encoder or dataset than the manifest it shipped with, and replay accepted it. The loader now: - validates manifest["encoder"] through _validate_encoder_provenance - requires record["encoder"] to equal it - requires each record's dataset_sha256 and source_manifest_sha256 to equal the manifest's - validates raw_window_sha256 as a lowercase hex digest - recomputes each state_id from dataset_sha256 + raw_window_sha256 rather than accepting any unique 64-character string state_id derivation moved into a shared helper so the writer and the replay check cannot drift apart. Publication commits the pair, not each file ------------------------------------------- Per-file os.replace still allowed a crash between the two writes to leave new records beside a stale manifest, destroying the previously valid pair. Both payloads are now built and validated before anything is written, and the manifest is written last as the commit marker. Republication is idempotent and non-destructive: byte-identical no-op, nothing written any difference refused, both files untouched records-only completable — this is the interrupted-publication repair, allowed only when the existing records match exactly Explicit replacement is deliberately not added until a workflow needs it. Identical state and manifest paths are rejected outright. Test paths were writing over the fixture ---------------------------------------- The immutability guard immediately caught that these tests published their state manifest to `root/manifest.json` — the same path test_pipeline._manifest writes the *source* manifest to. Every run had been silently clobbering the fixture. Artifacts now publish under `root/shadow/`. structured-state suite 13 -> 20 tests; NeuralComposeEEG suite 46 -> 53. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both blockers addressed in Blocker 1 — replay now binds records to their manifestThe loader was validating the two artifacts independently. It now requires agreement:
Blocker 2 — publication commits the pairBoth payloads are built and validated before anything is written; the manifest is written last as the commit marker. The overwrite policy is exactly as specified:
Explicit replacement deliberately not added. The guard immediately caught a live bugOn first run, 17 of 20 tests failed with AcceptanceOne deviation from the stated criteria worth flagging: the EEG suite is 53, not the expected 46. 46 assumed the pre-review 13 structured-state tests; the six required cases plus a |
|
Merged after independent review of the repaired head Final acceptance:
Verification at the merged head:
Merged as |
The structured-state replay test wrote its state manifest to root/manifest.json — the same path test_pipeline._manifest writes the *source* manifest to. That silently clobbered the fixture until #35 made the shadow bridge refuse to overwrite an existing artifact that differs. Same collision, same fix as #35 applied to its own suite: publish under root/shadow/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prerequisite for #33. Commits the structured-state module that #33's
test_synthetic_structured_state_replay_remains_shadow_onlyimports — it hadonly ever existed as an untracked working-tree file, so that test could not pass
on any checkout.
Scope
Nothing else. No schema document was needed — the module's
nc-eeg-encoder-state-v0shape is defined and enforced in code, and every dependency (
ContractError,CanonicalDataset,sha256_json) already exists on this base.What it does
Serializes offline encoder probabilities into a replayable shadow-state record
plus a hash-bound manifest. Both carry the same status block, enforced on write
and re-verified on read:
The reader re-derives
confidence,entropy_nats, andargmax_observablefromthe stored probabilities rather than trusting the written values, so a tampered
record is rejected even when its manifest hash has been recomputed to match it.
A forbidden-term check rejects any artifact whose serialized form mentions
dialogue, dialectic, prompt, speech, policy, action, waveform, label, or target.
Two changes made during audit
The module was audited as an independent component before staging, per the
acceptance contract. It was coherent and self-contained; two gaps were closed.
1. Publication is now atomic.
write_textcould leave a truncated artifactover a previously valid one if interrupted mid-write. Both outputs now publish
through a same-directory rename:
2. Eight tests added for invariants the module enforced but never proved:
The last one is the atomicity proof: a rejected republication leaves the prior
artifact byte-identical, leaves no
.partialbehind, and the prior artifactstill loads.
Acceptance contract status
Open decision: republication overwrites silently
The contract also listed "no path escape or silent overwrite". Path escape does
not apply — the operator names both output paths explicitly on the CLI, so there
is no root to escape from.
Silent overwrite is a real, deliberate behaviour and is left as authored:
re-running the tool over existing artifacts replaces them without prompting.
test_round_trip_is_deterministic_and_shadow_onlydepends on this, writing twiceto the same paths and asserting byte-identical output.
That is idempotent republication, which seems intended. Making it refuse to
overwrite differing content would be a semantic change to the publication
contract, so it is flagged here rather than made unilaterally. Say the word and
it becomes a follow-up.
Verification
Run with the pinned stack (numpy 1.26.4 / torch 2.4.1). The module itself needs
only numpy; torch enters through
test_pipeline, whose fixture helpers this testreuses.
Note that CI does not yet run any Python suite —
build-and-testis Swift-only,which is why the missing module was never caught. Wiring that up is a separate
small PR, to land before #33 goes ready.