diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa1dfa..ec82804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [0.2.6] — 2026-07-21 + +### Added +- **`mm_prereg_lint` tool** (19 tools total) — surfaces measure-mirror's new + `prereg_lint` (㉗): a seal-*quality* check to run right before spending compute. + Distinct from `mm_falsifiability_check` (presence) and the `mm_preflight` + existence gate — it flags a kill-condition leaked into the `metric` field, a + quantified kill with no structured threshold, a pass bar at/below chance, a + low `min_n`, or no declared pre-seal machine-checks. +- **`mm_preregister(pre_seal_checks=[...])`** passthrough. + +### Changed (deps) +- **measure-mirror pin `e2911ca` → `13077df`** (v0.25.0 → v0.26.0, #28): brings the + `prereg_lint` probe this release's tool and gate wiring require. + +### Changed +- **Compute gate reports a leaked kill-condition** (`gate.py`) — when a + pre-registration exists but its kill-condition leaked into `metric` (no kill + fields), `mm_preflight`/`mirror-stack-gate compute` still BLOCKs, now with the + accurate reason (pointing to `mm_prereg_lint`) instead of the misleading + "no sealed preregistration". + ## [0.2.5] — 2026-07-21 ### Changed diff --git a/README.md b/README.md index 897090c..b85259a 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,16 @@ dependencies — you don't clone four repos. (Apache-2.0, zero-dep cores.) No `cwd`, no `PYTHONPATH` — it's a proper installed entry point. Works in Claude Code, Cursor, Windsurf, any MCP client. -## Tools (18) +## Tools (19) | Tool | Mirror | Does | |---|---|---| -| `mm_preregister` | 🪞 claims | seal a claim + kill-condition **before** measuring | +| `mm_preregister` | 🪞 claims | seal a claim + kill-condition **before** measuring (response carries an auto seal-quality lint) | | `mm_verify` | 🪞 | umbrella verify — every probe whose input key is present | | `mm_audit` | 🪞 | audit a result vs its sealed registration | | `mm_power_check` | 🪞 | false-negative guard — is n big enough? (design-time) | | `mm_falsifiability_check` | 🪞 | Popper gate — kill-condition registered & not tripped? | +| `mm_prereg_lint` | 🪞 | seal *quality* lint (㉗) — leaked kill-condition, bar at/below chance, unstructured kill, low n, undeclared pre-seal checks; the compute gate BLOCKs on a lint FAIL | | `mm_leakage_check` | 🪞 | train∩test contamination | | `mm_multiseed_check` | 🪞 | unstable signal / lucky seed | | `mm_retract` | 🪞 | chain-linked retraction (cannot be silently deleted) | diff --git a/docs/STACK_CANONICAL.md b/docs/STACK_CANONICAL.md index 3da105a..501b59e 100644 --- a/docs/STACK_CANONICAL.md +++ b/docs/STACK_CANONICAL.md @@ -17,7 +17,7 @@ so "which one is authoritative?" has a written answer. | Surface | Lives in | Owns | |---|---|---| | **measure-mirror `stack/`** | [`bhyi4/measure-mirror`](https://github.com/bhyi4/measure-mirror) `stack/` | the stack **conventions + honesty docs** (`MIRROR_STACK.md`, `DISCIPLINE.md`, `PILLARS.md`, the case study); **`verify_self.py`** (one claims ledger: L1 self-chain + L3 anchors, zero external tool); **`verify_all.py`** (the orchestrator — adds L2 cross-witness *between* ledgers via `am`); `tombstone.py` | -| **mirror-stack-mcp** | this repo | the **agent MCP server** (`server.py`, 18 tools); the **`mirror-stack-gate`** enforcer CLI (`gate.py`); the **zero-config outsider** `mirror-stack-verify` CLI (`verify.py`); Bitcoin/OTS anchoring (`ots_anchor.py`) | +| **mirror-stack-mcp** | this repo | the **agent MCP server** (`server.py`, 19 tools); the **`mirror-stack-gate`** enforcer CLI (`gate.py`); the **zero-config outsider** `mirror-stack-verify` CLI (`verify.py`); Bitcoin/OTS anchoring (`ots_anchor.py`) | Rule of thumb: **measure-mirror `stack/` = the conventions and the self/orchestrated verification that ships with the library**; **mirror-stack-mcp = how an *agent* (MCP) diff --git a/mirror_stack_mcp/__init__.py b/mirror_stack_mcp/__init__.py index 4baca64..25a4c25 100644 --- a/mirror_stack_mcp/__init__.py +++ b/mirror_stack_mcp/__init__.py @@ -1,2 +1,2 @@ """🪞🔎🪪 Mirror Stack unified MCP server.""" -__version__ = "0.2.5" +__version__ = "0.2.6" diff --git a/mirror_stack_mcp/gate.py b/mirror_stack_mcp/gate.py index a6f6ada..1801e42 100644 --- a/mirror_stack_mcp/gate.py +++ b/mirror_stack_mcp/gate.py @@ -23,8 +23,14 @@ def scan_claim(ledger_path, claim_id): - """Return (prereg_entry_or_None, retracted_bool) for claim_id in a ledger.""" - prereg, retracted = None, False + """Return (prereg_entry_or_None, retracted_bool, leaked_entry_or_None) for claim_id. + + leaked_entry is a preregistration that carries NO kill fields but whose `metric` + reads like a kill-condition — i.e. the criterion leaked into the wrong field from a + malformed call. It is not a valid prereg (compute stays BLOCKed), but naming it lets + the gate report the real reason instead of a misleading 'no preregistration'. + """ + prereg, retracted, leaked = None, False, None if os.path.exists(ledger_path): with open(ledger_path, encoding="utf-8") as fh: for line in fh: @@ -42,12 +48,16 @@ def scan_claim(ledger_path, claim_id): elif e.get("_type") is None and ("kill_threshold" in e or "kill_condition" in e) \ and e.get("metric") != "protocol_amendment": prereg = e - return prereg, retracted + elif e.get("_type") is None and e.get("metric") not in (None, "protocol_amendment"): + from measure_mirror import mm + if leaked is None and mm._looks_like_kill_prose(e.get("metric", "")): + leaked = e + return prereg, retracted, leaked def decide(ledger_path, claim_id, gate="compute", am_ledger=None, reported_acc=None): """Pure GO/BLOCK decision. Returns {decision, gate, claim_id, reasons, checks}.""" - prereg, retracted = scan_claim(ledger_path, claim_id) + prereg, retracted, leaked = scan_claim(ledger_path, claim_id) checks: list[str] = [] def out(decision, reasons): @@ -55,6 +65,12 @@ def out(decision, reasons): "reasons": reasons, "checks": checks} if prereg is None: + if leaked is not None: + return out("BLOCK", [ + "a preregistration exists but its kill-condition leaked into the `metric` " + f"field ({str(leaked.get('metric'))[:60]!r}...) — the automated checks can't " + "parse it. Re-seal under a NEW claim_id with the criterion in kill_condition= " + "(mm_prereg_lint pinpoints this)."]) return out("BLOCK", ["no sealed preregistration for this claim — seal one " "(mm_preregister with a kill-condition) before proceeding"]) has_kill = bool(prereg.get("kill_threshold") or prereg.get("kill_condition")) @@ -64,6 +80,18 @@ def out(decision, reasons): if not has_kill: return out("BLOCK", ["preregistration has no kill-condition (unfalsifiable) — " "add one before spending compute"]) + # Seal-quality lint: a FAIL (e.g. a pass bar at/below chance) means the + # automated checks can't do their job — compute would be spent against a + # meaningless bar. WARN/INFO inform but don't block. + from measure_mirror import mm + lint = mm._preseal_lint(prereg) + fails = [f for f in lint if f.level == "FAIL"] + warns = [f for f in lint if f.level == "WARN"] + if warns: + checks.append("prereg-lint: " + "; ".join(f.msg for f in warns)) + if fails: + return out("BLOCK", ["prereg-lint FAIL — " + " | ".join(f.msg for f in fails)]) + checks.append("prereg-lint: no FAIL") return out("GO", ["sealed preregistration with a kill-condition is present"]) if gate == "publish": diff --git a/mirror_stack_mcp/server.py b/mirror_stack_mcp/server.py index 2d33fdb..a9b1a6c 100644 --- a/mirror_stack_mcp/server.py +++ b/mirror_stack_mcp/server.py @@ -38,17 +38,22 @@ BEFORE spending compute (seal first): 1. Preregister the claim WITH a kill-condition — mm_preregister(kill_threshold=...). - No falsification criterion = unfalsifiable. + No falsification criterion = unfalsifiable. Run the cheap machine-checks first and + declare them (pre_seal_checks=: reachability-smoke / mass-balance-audit / + neutral-control / manipulation-check / positive-control). 2. Power: is n big enough to detect the effect? mm_power_check (design-time). + 3. Lint the seal — mm_prereg_lint (auto in the mm_preregister response). A FAIL + (kill-condition leaked into the metric field, bar at/below chance) means the + automated checks can't fire: fix and re-seal under a NEW claim_id. BEFORE reporting (verify before speaking): - 3. Fair baseline, not crippled, same budget/data — mm_baseline_fairness. - 4. Gaming line: rewarding the metric is an artifact; only removal/swap is honest — mm_verify(reward_terms). - 5. Both directions — false positive AND false negative. Did you test the REAL target or a stand-in? - 6. Multi-seed + independent reproduction — mm_multiseed_check; am_witness from another agent. - 7. Scope: state what you closed and did NOT close — mm_verify(claimed_scope, tested_scope). - 8. Numeric + multiplicity hygiene — mm_verify (grim, multiple-comparisons). - 9. Self-catch: "too good" is suspect first. + 4. Fair baseline, not crippled, same budget/data — mm_baseline_fairness. + 5. Gaming line: rewarding the metric is an artifact; only removal/swap is honest — mm_verify(reward_terms). + 6. Both directions — false positive AND false negative. Did you test the REAL target or a stand-in? + 7. Multi-seed + independent reproduction — mm_multiseed_check; am_witness from another agent. + 8. Scope: state what you closed and did NOT close — mm_verify(claimed_scope, tested_scope). + 9. Numeric + multiplicity hygiene — mm_verify (grim, multiple-comparisons). + 10. Self-catch: "too good" is suspect first. If an LLM judge is involved, check it too (consistency / bias / swap / transitivity). THE RECORD: seal claims and actions (am_record target=); anchor externally @@ -132,6 +137,10 @@ def _compact(findings): "is inconclusive; raise n or narrow scope.", "mm_falsifiability_check": "🪞 If the kill-condition tripped (FAIL), the claim is falsified by " 'its OWN criterion → mm_retract it. If it did not trip, that\'s "not refuted", not "proven".', + "mm_prereg_lint": "🪞 Lint judges the seal's QUALITY, not just its presence. A FAIL here " + "(kill-condition leaked into `metric`, or a bar at/below chance) means the automated " + "checks can't fire — fix and re-seal under a NEW claim_id (first-write-wins). Run this " + "right before spending compute; it's the cheap machine-check that saves a KILL.", "mm_preflight": "🪞 This is a primitive — the MCP only judges GO/BLOCK. YOUR launcher / " "pre-commit hook must do the actual blocking; the MCP can't stop external compute or " "commits (by design, opt-in).", @@ -162,17 +171,23 @@ def mm_preregister(ledger_path: str, claim_id: str, metric: str, min_n: int = 20 baseline: float = 0.5, pass_threshold: float = 0.6, kill_condition: str | None = None, kill_threshold: dict | None = None, depends_on: list[str] | None = None, - metric_range: list | str | None = None, chance: float | None = None) -> dict: + metric_range: list | str | None = None, chance: float | None = None, + pre_seal_checks: list[str] | None = None) -> dict: """Seal a claim BEFORE measuring (preregistration). kill_condition/threshold = what falsifies it. For a non-[0,1] metric, declare metric_range (e.g. [0,100] for a %, or "unbounded" for a delta/span) + chance (the real chance level, e.g. 1/24≈0.042) so audit doesn't false-FAIL - or assume baseline 0.5. Omit for a plain [0,1] accuracy.""" - return _remind("mm_preregister", mm.preregister( + or assume baseline 0.5. Omit for a plain [0,1] accuracy. + + The response carries an automatic seal-quality lint (`lint` key): a FAIL there means + the compute gate will BLOCK this claim — fix and re-seal under a NEW claim_id.""" + entry = mm.preregister( ledger_path, claim_id, metric=metric, min_n=min_n, baseline=baseline, pass_threshold=pass_threshold, kill_condition=kill_condition, kill_threshold=kill_threshold, depends_on=depends_on, - metric_range=metric_range, chance=chance)) + metric_range=metric_range, chance=chance, pre_seal_checks=pre_seal_checks) + lint = _compact(_findings(mm._preseal_lint(entry))) + return _remind("mm_preregister", {**entry, "lint": lint}) @mcp.tool() @@ -217,6 +232,28 @@ def mm_falsifiability_check(ledger_path: str, claim_id: str, reported_acc=reported_acc, am_ledger=am_ledger))) +@mcp.tool() +def mm_prereg_lint(ledger_path: str, claim_id: str | None = None) -> list[str]: + """🔍 Lint a sealed preregistration for QUALITY defects — the cheap machine-check to + run right before spending compute. + + falsifiability_check / the compute gate ask 'does a kill-condition exist?'. This asks + 'is the seal well-formed enough that the automated checks can actually fire, and is the + bar meaningful?' — the failure classes that leak silent compute: + • kill-condition prose leaked into the `metric` field (a malformed call) — the human + eye sees a criterion, the parser sees none [FAIL] + • a quantified kill written as free text with no structured kill_threshold [WARN] + • a pass bar sitting at or below chance [FAIL] + • min_n below the small-sample floor [WARN] + • no cheap pre-seal machine-checks declared (reachability / accounting / neutral- + control / manipulation / positive-control) [INFO nudge] + + claim_id=None lints every preregistration in the ledger. A FAIL means fix and re-seal + under a NEW claim_id (first-write-wins makes the old one uncorrectable).""" + return _remind("mm_prereg_lint", + _compact(_findings(mm.prereg_lint(ledger_path, claim_id)))) + + @mcp.tool() def mm_leakage_check(train_items: list, test_items: list) -> str: """Detect train∩test contamination via hash intersection.""" diff --git a/pyproject.toml b/pyproject.toml index 9776b74..346002d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mirror-stack-mcp" -version = "0.2.5" +version = "0.2.6" description = "Unified MCP server for the Mirror Stack — claims, actions, provenance + verify-all in one server" readme = "README.md" requires-python = ">=3.10" @@ -16,7 +16,7 @@ dependencies = [ # tracks each mirror's default branch, so an install made before a mirror's release # freezes on whatever was HEAD then and never refreshes — that is how a local install # ran stale measure-mirror 0.14.3 long after 0.15.1 landed. Bump these on purpose. - "measure-mirror @ git+https://github.com/bhyi4/measure-mirror@e2911cae4fed4f32bbb51d3c748131dace8e2d1e", # v0.25.0 — catalog v1.8 / 45 entries (#26, docs-only); anchor-discipline probes ㉔㉕ + SPEC amendment A2 (anchor_cell/anchor_line_source/known_confounds); v0.24.0 grounding ㉑㉒㉓ + A1; v0.23.0 kill_threshold validation + "measure-mirror @ git+https://github.com/bhyi4/measure-mirror@13077df8c2616e0559742b2d97c37e2459d7906c", # v0.26.0 — ㉗ prereg_lint pre-compute seal-quality probe + pre_seal_checks (#28; required by mm_prereg_lint/gate lint wiring); v0.25.0 catalog v1.8 + anchor probes ㉔㉕ A2; v0.24.0 grounding ㉑㉒㉓ + A1 "action-mirror @ git+https://github.com/bhyi4/action-mirror@fd46e90c3e3f0706792c261316f4699d7a6799d8", # v0.1.0 (ledgers conform to MIRROR-SPEC v1.0) "provenance-mirror @ git+https://github.com/bhyi4/provenance-mirror@8edbbfd94772d3718de93271c1f80297c0476043", # v0.1.0 (ledgers conform to MIRROR-SPEC v1.0) ] diff --git a/tests/smoke_installed.py b/tests/smoke_installed.py index 88d9614..fc20e33 100644 --- a/tests/smoke_installed.py +++ b/tests/smoke_installed.py @@ -2,13 +2,13 @@ This is the exact check that would have caught our local install going silently stale: 'works in the repo, broken/old once installed'. It asserts the unified server imports -in a clean environment, exposes all 18 tools, and that the three re-exposed mirror +in a clean environment, exposes all 19 tools, and that the three re-exposed mirror packages are importable (a missing/renamed mirror dep fails here, loudly). """ import mirror_stack_mcp.server as s tools = {t.name for t in s.mcp._tool_manager.list_tools()} -assert len(tools) == 18, f"expected 18 tools, got {len(tools)}: {sorted(tools)}" +assert len(tools) == 19, f"expected 19 tools, got {len(tools)}: {sorted(tools)}" assert callable(s.main), "entry point mirror_stack_mcp.server:main missing" import measure_mirror # noqa: F401 re-exposed claim probes diff --git a/tests/test_server.py b/tests/test_server.py index 59f441c..e7b60dc 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,7 +1,7 @@ """Unit + smoke tests for the unified Mirror Stack MCP server. Covers what a fresh install / reconnect must get right: - • all 18 tools register (drift detector — adding/removing a tool fails this); + • all 19 tools register (drift detector — adding/removing a tool fails this); • the loop-safe defaults (compact output, once-per-session reminders); • signal-preserving compaction — OK/INFO collapse, but a WARN/FAIL is NEVER dropped (a hidden negative is the cardinal sin this server exists to prevent); @@ -16,7 +16,8 @@ EXPECTED_TOOLS = { # 🪞 measure-mirror (claims) "mm_preregister", "mm_verify", "mm_audit", "mm_power_check", - "mm_falsifiability_check", "mm_leakage_check", "mm_multiseed_check", + "mm_falsifiability_check", "mm_prereg_lint", "mm_leakage_check", + "mm_multiseed_check", "mm_retract", "mm_anchor", "mm_anchor_bitcoin", "mm_anchor_upgrade", "mm_anchor_verify", "mm_preflight", # 🪪 action-mirror @@ -37,10 +38,10 @@ def _write(path, entries): # ── registration / defaults ─────────────────────────────────────────────────── -def test_all_18_tools_registered(): +def test_all_19_tools_registered(): names = _registered() assert names == EXPECTED_TOOLS, f"tool drift: {names ^ EXPECTED_TOOLS}" - assert len(names) == 18 + assert len(names) == 19 def test_defaults_are_loop_safe(): @@ -156,6 +157,68 @@ def test_preflight_unknown_gate(tmp_path): assert s.mm_preflight(str(led), "c1", gate="bogus")["decision"] == "BLOCK" +# ── mm_prereg_lint (seal-quality) + leaked-kill gate ────────────────────────── +def test_prereg_lint_flags_leaked_kill_condition(tmp_path): + led = tmp_path / "mm.jsonl" + # criterion sealed into `metric`, no kill fields — the self-catch #2 shape + _write(led, [{"claim_id": "gate0", + "metric": "gene1 eq; KILL if delta < 0.03 across arms", + "min_n": 200, "baseline": 0.5, "pass_threshold": 0.6}]) + out = s.mm_prereg_lint(str(led), "gate0") + assert any("FAIL" in line and "leaked into" in line for line in out) + + +def test_prereg_lint_clean_seal_has_no_warn_or_fail(tmp_path): + led = tmp_path / "mm.jsonl" + s.mm_preregister(str(led), "good", metric="separation_d", min_n=240, + kill_threshold={"metric": "d", "threshold": 0.1, "direction": "below"}, + pre_seal_checks=["reachability-smoke", "neutral-control"]) + out = s.mm_prereg_lint(str(led), "good") + assert not [line for line in out if "WARN" in line or "FAIL" in line] + + +def test_preregister_response_carries_auto_lint(tmp_path): + led = tmp_path / "mm.jsonl" + r = s.mm_preregister(str(led), "c1", metric="acc", min_n=10, + kill_threshold={"metric": "acc", "threshold": 0.55, + "direction": "below"}) + assert "lint" in r + joined = " ".join(r["lint"]) + assert "small-sample floor" in joined # min_n=10 surfaces immediately + + +def test_compute_gate_blocks_on_lint_fail_below_chance_bar(tmp_path): + led = tmp_path / "mm.jsonl" + # well-formed kill fields, but the pass bar sits below chance → lint FAIL + s.mm_preregister(str(led), "c1", metric="acc", baseline=0.5, pass_threshold=0.45, + kill_threshold={"metric": "acc", "threshold": 0.4, + "direction": "below"}) + r = s.mm_preflight(str(led), "c1", gate="compute") + assert r["decision"] == "BLOCK" + assert "prereg-lint FAIL" in " ".join(r["reasons"]) + + +def test_compute_gate_go_passes_lint_check(tmp_path): + led = tmp_path / "mm.jsonl" + s.mm_preregister(str(led), "c1", metric="acc", min_n=240, + kill_threshold={"metric": "acc", "threshold": 0.55, + "direction": "below"}, + pre_seal_checks=["reachability-smoke"]) + r = s.mm_preflight(str(led), "c1", gate="compute") + assert r["decision"] == "GO" + assert any("prereg-lint: no FAIL" in c for c in r["checks"]) + + +def test_compute_gate_reports_leaked_kill_condition(tmp_path): + led = tmp_path / "mm.jsonl" + _write(led, [{"claim_id": "gate0", + "metric": "gene1 eq; KILL if delta < 0.03 across arms", + "min_n": 200, "baseline": 0.5, "pass_threshold": 0.6}]) + r = s.mm_preflight(str(led), "gate0", gate="compute") + assert r["decision"] == "BLOCK" + assert "leaked into" in " ".join(r["reasons"]) + + # ── cross-package integration: a real mirror dep must work end-to-end ────────── def test_stack_verify_all_on_a_freshly_sealed_ledger(tmp_path): led = tmp_path / "mm.jsonl"