feat: implement CORE Workbench W3 Ratification Corridor

This commit is contained in:
Shay 2026-06-03 20:13:36 -07:00
parent 2cb09223e6
commit 6d1f6bba0c
26 changed files with 1923 additions and 57 deletions

View file

@ -236,6 +236,7 @@ def apply_composition_claim(
surface_pattern: str | None = None,
pack_root: Path | None = None,
evidence_source: str = "math_audit",
ratifier_kind: str = "cli",
) -> CompositionRatificationReceipt:
"""Apply a reviewed composition claim to a math composition source file.
@ -343,6 +344,7 @@ def apply_composition_claim(
)
existing_evidence.append(claim.evidence_hash)
existing["evidence_hashes"] = sorted(set(existing_evidence))
existing["ratifier_kind"] = ratifier_kind
is_duplicate_evidence = True
else:
entries.append(
@ -352,6 +354,7 @@ def apply_composition_claim(
"polarity": polarity,
"provenance": provenance,
"evidence_hashes": [claim.evidence_hash],
"ratifier_kind": ratifier_kind,
}
)

View file

@ -223,6 +223,7 @@ def apply_frame_claim(
reviewer: str,
pack_root: Path | None = None,
evidence_source: str = "math_audit",
ratifier_kind: str = "cli",
) -> FrameRatificationReceipt:
"""Apply a reviewed frame claim to a math frame source file.
@ -320,6 +321,7 @@ def apply_frame_claim(
)
existing_evidence.append(claim.evidence_hash)
existing["evidence_hashes"] = sorted(set(existing_evidence))
existing["ratifier_kind"] = ratifier_kind
is_duplicate_evidence = True
else:
entries.append(
@ -329,6 +331,7 @@ def apply_frame_claim(
"polarity": polarity,
"provenance": provenance,
"evidence_hashes": [claim.evidence_hash],
"ratifier_kind": ratifier_kind,
}
)

View file

@ -197,6 +197,7 @@ def apply_lexical_claim(
category: str,
reviewer: str,
pack_root: Path | None = None,
ratifier_kind: str = "cli",
) -> RatificationReceipt:
"""Apply a reviewed lexical claim to a math source lexicon file.
@ -267,6 +268,7 @@ def apply_lexical_claim(
)
aliases.append(lemma)
parent["aliases"] = sorted(set(aliases))
parent["ratifier_kind"] = ratifier_kind
is_alias = True
aliased_to = parent_lemma
else:
@ -276,6 +278,7 @@ def apply_lexical_claim(
"category": category,
"aliases": [],
"provenance": provenance,
"ratifier_kind": ratifier_kind,
}
)

View file

@ -0,0 +1,70 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from chat.telemetry import JsonlBufferSink
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
def test_workbench_operator_telemetry_emission_and_redaction(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
api = setup_isolated_workbench(tmp_path, monkeypatch)
sink = JsonlBufferSink()
api.attach_telemetry_sink(sink)
# Generate proposal
proposal = make_and_write_proposal(
api,
proposed_change_kind="vocabulary_addition",
evidence_surface="testlemma",
evidence_sub_type="lexical",
missing_operator="drain_token",
refusal_reason="unknown_word",
)
# 1. Test operator_ratify success
body = json.dumps({"category": "drain_token"}).encode("utf-8")
response_ratify = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
assert response_ratify.status == 200
assert len(sink.lines) == 1
event = json.loads(sink.lines[-1])
assert event["event"] == "operator_ratify"
assert event["proposal_id"] == proposal.proposal_id
assert event["handler"] == "LexicalClaim"
assert event["outcome"] == "applied"
assert event["ratifier_kind"] == "workbench"
# Ensure no evidence surface is leaked
assert "testlemma" not in sink.lines[-1]
# 2. Test operator_ratify failure (rejected_precondition)
# Re-ratifying will fail (AlreadyRatified)
api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
assert len(sink.lines) == 2
event_fail = json.loads(sink.lines[-1])
assert event_fail["event"] == "operator_ratify"
assert event_fail["outcome"] == "rejected_precondition"
assert "testlemma" not in sink.lines[-1]
# 3. Test operator_reject
body_reject = json.dumps({"note": "Spam proposal"}).encode("utf-8")
response_reject = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/reject", body_reject)
assert response_reject.status == 200
assert len(sink.lines) == 3
event_reject = json.loads(sink.lines[-1])
assert event_reject["event"] == "operator_reject"
assert event_reject["note"] == "Spam proposal"
assert event_reject["handler"] == "LexicalClaim"
assert "testlemma" not in sink.lines[-1]
# 4. Test operator_defer
response_defer = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/defer", b"")
assert response_defer.status == 200
assert len(sink.lines) == 4
event_defer = json.loads(sink.lines[-1])
assert event_defer["event"] == "operator_defer"
assert event_defer["handler"] == "LexicalClaim"
assert "testlemma" not in sink.lines[-1]

View file

@ -0,0 +1,82 @@
from __future__ import annotations
import json
import functools
from pathlib import Path
import pytest
from generate.comprehension import lexicon as comprehension_lexicon
from generate.comprehension import lifecycle
from generate.comprehension.audit import audit_problem
from generate.comprehension.state import ReaderRefusal
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
REPO_ROOT = Path(__file__).resolve().parents[1]
CASES_PATH = REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "cases.jsonl"
def _use_pack_for_reader(monkeypatch: pytest.MonkeyPatch, pack_root: Path) -> None:
comprehension_lexicon._CACHE.clear()
@functools.cache
def _tmp_lexicon():
return comprehension_lexicon.load_lexicon(pack_root)
monkeypatch.setattr(lifecycle, "_get_lexicon", _tmp_lexicon)
def test_case_0050_remains_refused_after_ui_ratification(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
api = setup_isolated_workbench(tmp_path, monkeypatch)
pack_root = tmp_path / "en_core_math_v1"
# 1. UI Ratify Lexical Claim
proposal_lex = make_and_write_proposal(
api,
proposed_change_kind="vocabulary_addition",
evidence_surface="testlemma",
evidence_sub_type="lexical",
missing_operator="drain_token",
refusal_reason="unknown_word",
)
body_lex = json.dumps({"category": "drain_token"}).encode("utf-8")
resp_lex = api.handle("POST", f"/math-proposals/{proposal_lex.proposal_id}/ratify", body_lex)
assert resp_lex.status == 200
# 2. UI Ratify Frame Claim
proposal_frame = make_and_write_proposal(
api,
proposed_change_kind="frame_reclassification",
evidence_surface="gives",
evidence_sub_type="frame",
missing_operator="transfer_frame",
refusal_reason="unexpected_category",
)
body_frame = json.dumps({"category": "transfer_frame", "polarity": "affirms"}).encode("utf-8")
resp_frame = api.handle("POST", f"/math-proposals/{proposal_frame.proposal_id}/ratify", body_frame)
assert resp_frame.status == 200
# 3. UI Ratify Composition Claim
proposal_comp = make_and_write_proposal(
api,
proposed_change_kind="composition_reclassification",
evidence_surface="each",
evidence_sub_type="composition",
missing_operator="quantity_extraction",
refusal_reason="incomplete_operation",
)
body_comp = json.dumps({"category": "multiplicative_composition", "polarity": "affirms"}).encode("utf-8")
resp_comp = api.handle("POST", f"/math-proposals/{proposal_comp.proposal_id}/ratify", body_comp)
assert resp_comp.status == 200
# Hook the reader to use the mutated pack copy
_use_pack_for_reader(monkeypatch, pack_root)
# Load and check case 0050
cases = [json.loads(line) for line in CASES_PATH.read_text().splitlines()]
case = next(c for c in cases if c["case_id"] == "gsm8k-train-sample-v1-0050")
result, _rows = audit_problem(case["question"], case_id=case["case_id"])
assert isinstance(result, ReaderRefusal), "case 0050 must remain refused after ratification"
assert result.sentence_index == 0

View file

@ -0,0 +1,46 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
def test_workbench_ratify_category_allowlist_enforcement(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
api = setup_isolated_workbench(tmp_path, monkeypatch)
pack_root = tmp_path / "en_core_math_v1"
# 1. Test missing category
proposal_lex = make_and_write_proposal(
api,
proposed_change_kind="vocabulary_addition",
evidence_surface="testlemma",
evidence_sub_type="lexical",
)
# Sending POST with empty body should be dry-run (returns 200, applied=False)
response_dry = api.handle("POST", f"/math-proposals/{proposal_lex.proposal_id}/ratify", b"")
assert response_dry.status == 200
assert response_dry.payload["data"]["applied"] is False
# Sending invalid/empty category in body should raise 400
body_empty = json.dumps({"category": ""}).encode("utf-8")
response_empty = api.handle("POST", f"/math-proposals/{proposal_lex.proposal_id}/ratify", body_empty)
assert response_empty.status == 400
assert response_empty.payload["ok"] is False
bad_file = pack_root / "lexicon" / "accumulation_verb.jsonl"
before_content = bad_file.read_bytes() if bad_file.exists() else b""
# 2. Test off-allowlist category
body_bad = json.dumps({"category": "accumulation_verb"}).encode("utf-8") # Lexical safe list is only drain_token
response_bad = api.handle("POST", f"/math-proposals/{proposal_lex.proposal_id}/ratify", body_bad)
assert response_bad.status == 400
assert response_bad.payload["ok"] is False
assert "drain-class" in response_bad.payload["error"]["message"] or "restricted" in response_bad.payload["error"]["message"]
# Verify nothing was written to accumulation_verb.jsonl
after_content = bad_file.read_bytes() if bad_file.exists() else b""
assert before_content == after_content

View file

@ -0,0 +1,63 @@
from __future__ import annotations
import json
import getpass
from pathlib import Path
import pytest
from teaching.math_composition_ratification import apply_composition_claim
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
def test_workbench_ratify_composition_byte_equivalence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
api = setup_isolated_workbench(tmp_path, monkeypatch)
pack_root = tmp_path / "en_core_math_v1"
target_file = pack_root / "compositions" / "multiplicative_composition.jsonl"
# Write a clean target file
target_file.write_text("", encoding="utf-8")
# Generate proposal
proposal = make_and_write_proposal(
api,
proposed_change_kind="composition_reclassification",
evidence_surface="each",
evidence_sub_type="composition",
missing_operator="quantity_extraction",
refusal_reason="incomplete_operation",
)
# 1. UI Ratify
body = json.dumps({"category": "multiplicative_composition", "polarity": "affirms"}).encode("utf-8")
response = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
assert response.status == 200
assert response.payload["ok"] is True
assert response.payload["data"]["applied"] is True
ui_lines = target_file.read_bytes().splitlines()
assert len(ui_lines) == 1
# Clear target file
target_file.write_text("", encoding="utf-8")
# 2. CLI Ratify
claim = proposal.evidence_pointers[0]
apply_composition_claim(
claim=claim,
composition_category="multiplicative_composition",
polarity="affirms",
reviewer=getpass.getuser(),
pack_root=pack_root,
ratifier_kind="cli",
)
cli_lines = target_file.read_bytes().splitlines()
assert len(cli_lines) == 1
# 3. Assert byte equivalence except for ratifier_kind
ui_line = ui_lines[0].decode("utf-8")
cli_line = cli_lines[0].decode("utf-8")
assert ui_line.replace('"ratifier_kind": "workbench"', '"ratifier_kind": "cli"').replace('"ratifier_kind":"workbench"', '"ratifier_kind":"cli"') == cli_line

View file

@ -0,0 +1,50 @@
from __future__ import annotations
import json
from unittest.mock import Mock
import pytest
from workbench.server import WorkbenchRequestHandler
def test_cors_host_and_origin_verification() -> None:
handler = Mock(spec=WorkbenchRequestHandler)
handler.headers = {"Host": "malicious.com", "Origin": "http://malicious-origin.com"}
handler.command = "GET"
handler.path = "/health"
handler.wfile = Mock()
# Bind the real method to the mock instance
handler._handle = WorkbenchRequestHandler._handle.__get__(handler, WorkbenchRequestHandler)
handler._send_common_headers = Mock()
# Run the handler
handler._handle()
# Assert response was 400 Bad Request
handler.send_response.assert_called_with(400)
# Verify wfile.write payload
handler.wfile.write.assert_called()
payload = json.loads(handler.wfile.write.call_args[0][0].decode("utf-8"))
assert payload["ok"] is False
assert "CORS check failed" in payload["error"]
def test_cors_loopback_host_and_origin_allowed() -> None:
handler = Mock(spec=WorkbenchRequestHandler)
handler.headers = {"Host": "127.0.0.1:8765", "Origin": "http://localhost:5173"}
handler.command = "GET"
handler.path = "/health"
handler.wfile = Mock()
handler.api = Mock()
handler.api.handle.return_value = Mock(status=200, payload={"ok": True})
# Bind the real method
handler._handle = WorkbenchRequestHandler._handle.__get__(handler, WorkbenchRequestHandler)
handler._send_common_headers = Mock()
# Run the handler
handler._handle()
# Assert response was 200 OK (not 400)
handler.send_response.assert_called_with(200)

View file

@ -0,0 +1,44 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
def test_workbench_ratify_exception_surface_verbatim(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
api = setup_isolated_workbench(tmp_path, monkeypatch)
# 1. Trigger WrongClaimSubType: Lexical proposal but frame sub_type
proposal_lex = make_and_write_proposal(
api,
proposed_change_kind="vocabulary_addition",
evidence_surface="testlemma",
evidence_sub_type="frame", # wrong sub_type for LexicalClaim
missing_operator="drain_token",
refusal_reason="unknown_word",
)
body = json.dumps({"category": "drain_token"}).encode("utf-8")
response_sub = api.handle("POST", f"/math-proposals/{proposal_lex.proposal_id}/ratify", body)
assert response_sub.status == 400
assert response_sub.payload["ok"] is False
# Verbatim error message from WrongClaimSubType
assert "Lexical ratification requires sub_type='lexical'" in response_sub.payload["error"]["message"]
# 2. Trigger WrongCompositionCategory
proposal_comp = make_and_write_proposal(
api,
proposed_change_kind="composition_reclassification",
evidence_surface="each",
evidence_sub_type="composition",
missing_operator="quantity_extraction",
refusal_reason="incomplete_operation",
)
body_bad_comp = json.dumps({"category": "distributive_composition", "polarity": "affirms"}).encode("utf-8")
response_comp = api.handle("POST", f"/math-proposals/{proposal_comp.proposal_id}/ratify", body_bad_comp)
assert response_comp.status == 400
assert response_comp.payload["ok"] is False
assert "CompositionClaim ratification is allowlist-only" in response_comp.payload["error"]["message"]

View file

@ -0,0 +1,63 @@
from __future__ import annotations
import json
import getpass
from pathlib import Path
import pytest
from teaching.math_frame_ratification import apply_frame_claim
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
def test_workbench_ratify_frame_byte_equivalence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
api = setup_isolated_workbench(tmp_path, monkeypatch)
pack_root = tmp_path / "en_core_math_v1"
target_file = pack_root / "frames" / "transfer_frame.jsonl"
# Write a clean target file
target_file.write_text("", encoding="utf-8")
# Generate proposal
proposal = make_and_write_proposal(
api,
proposed_change_kind="frame_reclassification",
evidence_surface="gives",
evidence_sub_type="frame",
missing_operator="transfer_frame",
refusal_reason="unexpected_category",
)
# 1. UI Ratify
body = json.dumps({"category": "transfer_frame", "polarity": "affirms"}).encode("utf-8")
response = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
assert response.status == 200
assert response.payload["ok"] is True
assert response.payload["data"]["applied"] is True
ui_lines = target_file.read_bytes().splitlines()
assert len(ui_lines) == 1
# Clear target file
target_file.write_text("", encoding="utf-8")
# 2. CLI Ratify
claim = proposal.evidence_pointers[0]
apply_frame_claim(
claim=claim,
frame_category="transfer_frame",
polarity="affirms",
reviewer=getpass.getuser(),
pack_root=pack_root,
ratifier_kind="cli",
)
cli_lines = target_file.read_bytes().splitlines()
assert len(cli_lines) == 1
# 3. Assert byte equivalence except for ratifier_kind
ui_line = ui_lines[0].decode("utf-8")
cli_line = cli_lines[0].decode("utf-8")
assert ui_line.replace('"ratifier_kind": "workbench"', '"ratifier_kind": "cli"').replace('"ratifier_kind":"workbench"', '"ratifier_kind":"cli"') == cli_line

View file

@ -0,0 +1,33 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
def test_workbench_ratify_idempotent_raises_already_ratified(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
api = setup_isolated_workbench(tmp_path, monkeypatch)
# Generate proposal
proposal = make_and_write_proposal(
api,
proposed_change_kind="vocabulary_addition",
evidence_surface="testlemma",
evidence_sub_type="lexical",
missing_operator="drain_token",
refusal_reason="unknown_word",
)
body = json.dumps({"category": "drain_token"}).encode("utf-8")
# First ratification succeeds
response1 = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
assert response1.status == 200
assert response1.payload["data"]["applied"] is True
# Second ratification fails with 409 Conflict (AlreadyRatified)
response2 = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
assert response2.status == 409
assert response2.payload["ok"] is False
assert "already ratified" in response2.payload["error"]["message"].lower()

View file

@ -0,0 +1,63 @@
from __future__ import annotations
import json
import getpass
from pathlib import Path
import pytest
from teaching.math_lexical_ratification import apply_lexical_claim
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
def test_workbench_ratify_lexical_byte_equivalence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
api = setup_isolated_workbench(tmp_path, monkeypatch)
pack_root = tmp_path / "en_core_math_v1"
target_file = pack_root / "lexicon" / "drain_token.jsonl"
# Write a clean target file
target_file.write_text("", encoding="utf-8")
# Generate proposal
proposal = make_and_write_proposal(
api,
proposed_change_kind="vocabulary_addition",
evidence_surface="testlemma",
evidence_sub_type="lexical",
missing_operator="drain_token",
refusal_reason="unknown_word",
)
# 1. UI Ratify
body = json.dumps({"category": "drain_token"}).encode("utf-8")
response = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
assert response.status == 200
assert response.payload["ok"] is True
assert response.payload["data"]["applied"] is True
ui_lines = target_file.read_bytes().splitlines()
assert len(ui_lines) == 1
# Clear target file
target_file.write_text("", encoding="utf-8")
# 2. CLI Ratify
claim = proposal.evidence_pointers[0]
apply_lexical_claim(
claim=claim,
category="drain_token",
reviewer=getpass.getuser(),
pack_root=pack_root,
ratifier_kind="cli",
)
cli_lines = target_file.read_bytes().splitlines()
assert len(cli_lines) == 1
# 3. Assert byte equivalence except for ratifier_kind
ui_line = ui_lines[0].decode("utf-8")
cli_line = cli_lines[0].decode("utf-8")
# Replacing ratifier_kind="workbench" with "cli" in the UI-serialized line should make it identical to the CLI-serialized line.
assert ui_line.replace('"ratifier_kind": "workbench"', '"ratifier_kind": "cli"').replace('"ratifier_kind":"workbench"', '"ratifier_kind":"cli"') == cli_line

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
def test_workbench_ratify_no_auto_transition(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
api = setup_isolated_workbench(tmp_path, monkeypatch)
pack_root = tmp_path / "en_core_math_v1"
# 1. Create a replay-equivalent proposal in the queue
proposal = make_and_write_proposal(
api,
proposed_change_kind="vocabulary_addition",
evidence_surface="testlemma",
evidence_sub_type="lexical",
missing_operator="drain_token",
refusal_reason="unknown_word",
)
# 2. Check that no entries have been written to the target file
lex_file = pack_root / "lexicon" / "drain_token.jsonl"
# It might contain existing lemmas from the copy, but should NOT contain "testlemma"
content_before = lex_file.read_text(encoding="utf-8") if lex_file.exists() else ""
assert "testlemma" not in content_before
# 3. Emulate polling/idle state: assert that even after time/polling, nothing mutates the target file
content_after_idle = lex_file.read_text(encoding="utf-8") if lex_file.exists() else ""
assert "testlemma" not in content_after_idle

View file

@ -0,0 +1,45 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
def test_workbench_ratify_partition_enforcement(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
api = setup_isolated_workbench(tmp_path, monkeypatch)
# Create a proposal with domain="math" initially (so build_proposal succeeds)
proposal = make_and_write_proposal(
api,
domain="math",
proposed_change_kind="vocabulary_addition",
evidence_surface="testlemma",
evidence_sub_type="lexical",
missing_operator="drain_token",
refusal_reason="unknown_word",
)
# Overwrite the domain key directly on disk to "cognition" to test partition isolation
import workbench.readers as readers
path = readers.MATH_PROPOSALS_JSONL
lines = path.read_text(encoding="utf-8").splitlines()
modified_lines = []
for line in lines:
if line.strip():
record = json.loads(line)
if record["proposal_id"] == proposal.proposal_id:
record["domain"] = "cognition"
modified_lines.append(json.dumps(record, ensure_ascii=False, separators=(",", ":")))
path.write_text("\n".join(modified_lines) + "\n", encoding="utf-8")
# Try to ratify it via the math API
body = json.dumps({"category": "drain_token"}).encode("utf-8")
response = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
# Should be rejected with 400 Bad Request
assert response.status == 400
assert response.payload["ok"] is False
assert "partition" in response.payload["error"]["message"].lower()

View file

@ -0,0 +1,130 @@
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from evals.refusal_taxonomy.shape_categories import ShapeCategory
from generate.comprehension.audit import AuditRow
from teaching.math_contemplation_proposal import build_proposal, to_jsonl_record
from teaching.math_evidence import from_audit_row
from teaching.math_reasoning_trace import ReasoningStep, build_trace
from workbench.api import WorkbenchApi
from workbench import readers
REPO_ROOT = Path(__file__).resolve().parents[1]
PACK_ROOT = REPO_ROOT / "language_packs" / "data" / "en_core_math_v1"
def setup_isolated_workbench(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> WorkbenchApi:
# 1. Copy en_core_math_v1 to tmp_path
target_pack = tmp_path / "en_core_math_v1"
shutil.copytree(PACK_ROOT, target_pack)
(target_pack / "lexicon").mkdir(exist_ok=True)
(target_pack / "frames").mkdir(exist_ok=True)
(target_pack / "compositions").mkdir(exist_ok=True)
# Clear caches
from generate.comprehension import lexicon as comprehension_lexicon
from generate.comprehension import lifecycle
comprehension_lexicon._CACHE.clear()
lifecycle._get_lexicon.cache_clear()
# 2. Redirect pack root in ratification handlers
monkeypatch.setattr("teaching.math_lexical_ratification._default_pack_root", lambda: target_pack)
monkeypatch.setattr("teaching.math_frame_ratification._default_pack_root", lambda: target_pack)
monkeypatch.setattr("teaching.math_composition_ratification._default_pack_root", lambda: target_pack)
# Redirect proposal file
proposals_jsonl = tmp_path / "proposals.jsonl"
proposals_jsonl.touch()
monkeypatch.setattr(readers, "MATH_PROPOSALS_JSONL", proposals_jsonl)
# Create WorkbenchApi
api = WorkbenchApi()
return api
def build_test_claim(surface: str, sub_type: str, case_id: str | None = None, missing_operator: str = "lexicon_entry", refusal_reason: str = "unknown_word") -> Any:
row = AuditRow(
case_id=case_id or f"case-{surface}",
sentence_index=0,
token_index=2,
token_text=surface,
recognized_terms=("Ava", "counts"),
skipped_frame=None,
missing_operator=missing_operator,
refusal_reason=refusal_reason,
refusal_detail=f"test refusal for '{surface}'",
)
return from_audit_row(row, sub_type)
def write_proposal_record(api: WorkbenchApi, proposal_id: str, record: dict[str, Any]) -> None:
# Append to the mocked proposals.jsonl
path = readers.MATH_PROPOSALS_JSONL
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
def make_and_write_proposal(
api: WorkbenchApi,
*,
domain: str = "math",
shape_category: ShapeCategory = ShapeCategory.UNCATEGORIZED,
proposed_change_kind: str = "vocabulary_addition",
evidence_surface: str = "widgets",
evidence_sub_type: str = "lexical",
missing_operator: str = "lexicon_entry",
refusal_reason: str = "unknown_word",
wrong_zero_assertion: str = "Proposal wrong zero assertion must be at least forty chars long.",
) -> Any:
ev1 = build_test_claim(evidence_surface, evidence_sub_type, case_id="case-1", missing_operator=missing_operator, refusal_reason=refusal_reason)
ev2 = build_test_claim(evidence_surface, evidence_sub_type, case_id="case-2", missing_operator=missing_operator, refusal_reason=refusal_reason)
steps = (
ReasoningStep(
step_index=0,
step_kind="observation",
input_pointers=("case-1", "case-2"),
claim="refusals share pattern",
justification="grouped by key",
output_payload={"evidence_count": 2},
),
ReasoningStep(
step_index=1,
step_kind="grouping",
input_pointers=("case-1", "case-2"),
claim="group key",
justification="pair equality",
output_payload={"k": "v"},
),
ReasoningStep(
step_index=2,
step_kind="hypothesis",
input_pointers=("case-1", "case-2"),
claim="change fits",
justification="fits shape",
output_payload={"proposed_change_kind": proposed_change_kind},
),
ReasoningStep(
step_index=3,
step_kind="conclusion",
input_pointers=("case-1", "case-2"),
claim=f"propose {proposed_change_kind}",
justification="evidence-only proposal",
output_payload={"proposed_change_kind": proposed_change_kind},
),
)
trace = build_trace(steps)
proposal = build_proposal(
domain=domain, # type: ignore[arg-type]
shape_category=shape_category,
structural_commonality="test commonality",
evidence_pointers=(ev1, ev2),
proposed_change_kind=proposed_change_kind,
proposed_change_payload={"k": "v"},
wrong_zero_assertion=wrong_zero_assertion,
replay_equivalence_hash="0" * 64,
reasoning_trace=trace,
)
record = to_jsonl_record(proposal)
write_proposal_record(api, proposal.proposal_id, record)
return proposal

View file

@ -104,7 +104,7 @@
"path": "str",
"original": "Any",
"replay": "Any",
"severity": "Literal['info', 'warning', 'failure']"
"severity": "ReplayDivergenceSeverity"
}
},
"ReplayComparison": {
@ -115,6 +115,51 @@
"equivalent": "bool",
"divergences": "list[ReplayDivergence]"
}
},
"MathReasoningStep": {
"fields": {
"step_index": "int",
"step_kind": "str",
"claim": "str",
"justification": "str",
"input_pointers": "list[str]",
"output_payload": "Any"
}
},
"MathProposalSummary": {
"fields": {
"proposal_id": "str",
"domain": "Literal['math']",
"shape_category": "str",
"proposed_change_kind": "str",
"structural_commonality": "str",
"evidence_count": "int",
"replay_equivalence_hash": "str"
}
},
"MathProposalDetail": {
"fields": {
"wrong_zero_assertion": "str",
"proposed_change_payload": "Any",
"reasoning_trace_id": "str",
"reasoning_trace_steps": "list[MathReasoningStep]",
"evidence_hashes": "list[str]",
"handler_name": "str | None",
"suggested_ratify_cli": "str | None"
}
},
"MathRatifyResult": {
"fields": {
"proposal_id": "str",
"change_kind": "str",
"handler_name": "str",
"routing_status": "Literal['routed', 'not_implemented']",
"message": "str",
"suggested_cli": "str | None",
"applied": "bool",
"target_path": "str | None",
"evidence_hash": "str | None"
}
}
}
}

View file

@ -10,6 +10,9 @@ import type {
ProposalDetail,
ProposalState,
ProposalSummary,
MathProposalSummary,
MathProposalDetail,
MathRatifyResult,
} from "../types/api";
export class WorkbenchApiError extends Error {
@ -103,3 +106,52 @@ export async function fetchProposals(
export async function fetchProposalDetail(proposalId: string): Promise<ProposalDetail> {
return apiFetch<ProposalDetail>(`/proposals/${encodeURIComponent(proposalId)}`);
}
export async function fetchMathProposals(): Promise<MathProposalSummary[]> {
const envelope = await apiFetch<ItemsEnvelope<MathProposalSummary>>("/math-proposals");
return envelope.items;
}
export async function fetchMathProposalDetail(proposalId: string): Promise<MathProposalDetail> {
return apiFetch<MathProposalDetail>(`/math-proposals/${encodeURIComponent(proposalId)}`);
}
export async function ratifyMathProposal(
proposalId: string,
category?: string,
polarity?: string,
dryRun?: boolean,
): Promise<MathRatifyResult> {
return apiFetch<MathRatifyResult>(`/math-proposals/${encodeURIComponent(proposalId)}/ratify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category, polarity, dry_run: dryRun }),
});
}
export async function rejectMathProposal(
proposalId: string,
note?: string,
): Promise<{ proposal_id: string; rejected: boolean }> {
return apiFetch<{ proposal_id: string; rejected: boolean }>(
`/math-proposals/${encodeURIComponent(proposalId)}/reject`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
},
);
}
export async function deferMathProposal(
proposalId: string,
): Promise<{ proposal_id: string; deferred: boolean }> {
return apiFetch<{ proposal_id: string; deferred: boolean }>(
`/math-proposals/${encodeURIComponent(proposalId)}/defer`,
{
method: "POST",
},
);
}

View file

@ -13,6 +13,11 @@ import {
fetchReplayComparison,
fetchProposalDetail,
fetchProposals,
fetchMathProposals,
fetchMathProposalDetail,
ratifyMathProposal,
rejectMathProposal,
deferMathProposal,
type ProposalStateFilter,
} from "./client";
import type { WorkbenchApiError } from "./client";
@ -27,6 +32,9 @@ import type {
ChatTurnResult,
EvalRunRequest,
ReplayComparison,
MathProposalSummary,
MathProposalDetail,
MathRatifyResult,
} from "../types/api";
export { QueryClientProvider };
@ -136,3 +144,76 @@ export function useChatTurn() {
});
}
export function useMathProposals() {
return useQuery<MathProposalSummary[]>({
queryKey: ["api", "math-proposals"],
queryFn: fetchMathProposals,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function useMathProposalDetail(proposalId: string) {
return useQuery<MathProposalDetail>({
queryKey: ["api", "math-proposal", proposalId],
queryFn: () => fetchMathProposalDetail(proposalId),
enabled: !!proposalId,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function useMathRatify() {
return useMutation<
MathRatifyResult,
WorkbenchApiError,
{ proposalId: string; category?: string; polarity?: string; dryRun?: boolean }
>({
mutationKey: ["math-ratify"],
mutationFn: ({ proposalId, category, polarity, dryRun }) =>
ratifyMathProposal(proposalId, category, polarity, dryRun),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ["api", "proposals"] });
queryClient.invalidateQueries({ queryKey: ["api", "math-proposals"] });
queryClient.invalidateQueries({ queryKey: ["api", "proposal", variables.proposalId] });
queryClient.invalidateQueries({ queryKey: ["api", "math-proposal", variables.proposalId] });
},
});
}
export function useMathReject() {
return useMutation<
{ proposal_id: string; rejected: boolean },
WorkbenchApiError,
{ proposalId: string; note?: string }
>({
mutationKey: ["math-reject"],
mutationFn: ({ proposalId, note }) => rejectMathProposal(proposalId, note),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ["api", "proposals"] });
queryClient.invalidateQueries({ queryKey: ["api", "math-proposals"] });
queryClient.invalidateQueries({ queryKey: ["api", "proposal", variables.proposalId] });
queryClient.invalidateQueries({ queryKey: ["api", "math-proposal", variables.proposalId] });
},
});
}
export function useMathDefer() {
return useMutation<
{ proposal_id: string; deferred: boolean },
WorkbenchApiError,
{ proposalId: string }
>({
mutationKey: ["math-defer"],
mutationFn: ({ proposalId }) => deferMathProposal(proposalId),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ["api", "proposals"] });
queryClient.invalidateQueries({ queryKey: ["api", "math-proposals"] });
queryClient.invalidateQueries({ queryKey: ["api", "proposal", variables.proposalId] });
queryClient.invalidateQueries({ queryKey: ["api", "math-proposal", variables.proposalId] });
},
});
}

View file

@ -11,10 +11,12 @@ const ROW_INCREMENT = 40;
export function ProposalTable({
proposals,
selectedProposalId,
focusedProposalId,
onSelect,
}: {
proposals: ProposalSummary[];
selectedProposalId: string | null;
focusedProposalId?: string | null;
onSelect: (proposalId: string) => void;
}) {
const [visibleCount, setVisibleCount] = useState(INITIAL_ROWS);
@ -44,14 +46,22 @@ export function ProposalTable({
<div className="max-h-[calc(100vh-17rem)] overflow-y-auto">
{visibleProposals.map((proposal) => {
const selected = proposal.proposal_id === selectedProposalId;
const focused = proposal.proposal_id === focusedProposalId;
return (
<div
aria-current={selected ? "true" : undefined}
role="button"
tabIndex={0}
className="grid w-full grid-cols-[minmax(7rem,1fr)_auto_auto_minmax(7rem,1fr)_minmax(9rem,1fr)] items-center gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] aria-[current=true]:bg-[var(--color-surface-inset)]"
className={`grid w-full grid-cols-[minmax(7rem,1fr)_auto_auto_minmax(7rem,1fr)_minmax(9rem,1fr)] items-center gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] transition-all ${
selected ? "bg-[var(--color-surface-inset)]" : ""
} ${
focused
? "bg-[var(--color-surface-inset)] border-l-2 border-[var(--color-focus-ring)] pl-[10px]"
: "border-l-2 border-transparent pl-[10px]"
}`}
key={proposal.proposal_id}
onClick={() => onSelect(proposal.proposal_id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();

View file

@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { AlertTriangle } from "lucide-react";
import { WorkbenchApiError, type ProposalStateFilter } from "../../api/client";
import { useProposalDetail, useProposals } from "../../api/queries";
import { useProposalDetail, useProposals, useMathProposals, useMathProposalDetail } from "../../api/queries";
import type { ProposalSummary, MathProposalDetail, ProposalState, DownstreamEffect, MathReasoningStep } from "../../types/api";
import { Button } from "../../design/components/primitives/Button";
import { EmptyState } from "../../design/components/states/EmptyState";
import { ErrorState } from "../../design/components/states/ErrorState";
@ -11,11 +13,19 @@ import { ProposalProvenanceViewer } from "./ProposalProvenanceViewer";
import { ProposalSummaryCard } from "./ProposalSummaryCard";
import { ProposalTable } from "./ProposalTable";
import { ReplayEvidenceCard } from "./ReplayEvidenceCard";
import { RatificationCommandPanel } from "./RatificationCommandPanel";
const filters: ProposalStateFilter[] = ["pending", "accepted", "rejected", "all"];
function isProposalFilter(value: string | null): value is ProposalStateFilter {
return value === "pending" || value === "accepted" || value === "rejected" || value === "withdrawn" || value === "unknown" || value === "all";
return (
value === "pending" ||
value === "accepted" ||
value === "rejected" ||
value === "withdrawn" ||
value === "unknown" ||
value === "all"
);
}
function errorMessage(error: unknown) {
@ -26,13 +36,32 @@ export function ProposalsRoute() {
const [searchParams, setSearchParams] = useSearchParams();
const selectedFromUrl = searchParams.get("proposal_id");
const filterFromUrl = searchParams.get("state");
const domainFromUrl = searchParams.get("domain");
const [domain, setDomain] = useState<"math" | "cognition">(
domainFromUrl === "math" ? "math" : "cognition"
);
const [filter, setFilter] = useState<ProposalStateFilter>(
isProposalFilter(filterFromUrl) ? filterFromUrl : "pending",
);
const proposalsQuery = useProposals(filter);
const selectedProposalId = selectedFromUrl ?? null;
const detailQuery = useProposalDetail(selectedProposalId ?? "");
const [focusedIndex, setFocusedIndex] = useState<number>(0);
// Queries
const mathProposalsQuery = useMathProposals();
const cognitionProposalsQuery = useProposals(filter);
const selectedProposalId = selectedFromUrl ?? null;
const mathDetailQuery = useMathProposalDetail(
domain === "math" ? (selectedProposalId ?? "") : ""
);
const cognitionDetailQuery = useProposalDetail(
domain === "cognition" ? (selectedProposalId ?? "") : ""
);
// Synchronize state from URL
useEffect(() => {
const urlFilter = searchParams.get("state");
if (isProposalFilter(urlFilter) && urlFilter !== filter) {
@ -40,12 +69,62 @@ export function ProposalsRoute() {
}
}, [filter, searchParams]);
const proposals = useMemo(() => proposalsQuery.data ?? [], [proposalsQuery.data]);
useEffect(() => {
const urlDomain = searchParams.get("domain");
if ((urlDomain === "math" || urlDomain === "cognition") && urlDomain !== domain) {
setDomain(urlDomain as "math" | "cognition");
}
}, [domain, searchParams]);
function updateRoute(next: { proposalId?: string | null; state?: ProposalStateFilter }) {
// Load appropriate proposals list
const rawProposals = useMemo(() => {
if (domain === "math") {
return mathProposalsQuery.data ?? [];
} else {
return cognitionProposalsQuery.data ?? [];
}
}, [domain, mathProposalsQuery.data, cognitionProposalsQuery.data]);
// Map to unified ProposalSummary structure
const proposals: ProposalSummary[] = useMemo(() => {
if (domain === "math") {
return (rawProposals as any[]).map((mp) => ({
proposal_id: mp.proposal_id,
state: "pending" as ProposalState,
source_kind: `math / ${mp.proposed_change_kind}`,
replay_equivalent: true,
created_at: null,
downstream_effect: "unknown" as DownstreamEffect,
}));
} else {
return (rawProposals as ProposalSummary[]) ?? [];
}
}, [domain, rawProposals]);
const focusedProposalId = useMemo(() => {
if (proposals.length > 0 && focusedIndex >= 0 && focusedIndex < proposals.length) {
return proposals[focusedIndex].proposal_id;
}
return null;
}, [proposals, focusedIndex]);
// Reset focus when domain or filter changes
useEffect(() => {
setFocusedIndex(0);
}, [domain, filter]);
function updateRoute(next: { proposalId?: string | null; state?: ProposalStateFilter; domain?: "math" | "cognition" }) {
const params = new URLSearchParams(searchParams);
const nextDomain = next.domain ?? domain;
const nextState = next.state ?? filter;
if (nextDomain === "math") {
params.set("domain", "math");
} else {
params.delete("domain");
}
params.set("state", nextState);
if (next.proposalId === null) {
params.delete("proposal_id");
} else if (next.proposalId) {
@ -59,30 +138,126 @@ export function ProposalsRoute() {
updateRoute({ state: nextFilter, proposalId: null });
}
function changeDomain(nextDomain: "math" | "cognition") {
setDomain(nextDomain);
updateRoute({ domain: nextDomain, proposalId: null });
}
function selectProposal(proposalId: string) {
const index = proposals.findIndex((p) => p.proposal_id === proposalId);
if (index !== -1) {
setFocusedIndex(index);
}
updateRoute({ proposalId });
}
if (proposalsQuery.isLoading) {
// Keyboard navigation wires
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT" || document.activeElement?.tagName === "TEXTAREA") {
return;
}
if (e.key === "j" || e.key === "ArrowDown") {
e.preventDefault();
setFocusedIndex((prev) => Math.min(prev + 1, proposals.length - 1));
} else if (e.key === "k" || e.key === "ArrowUp") {
e.preventDefault();
setFocusedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (proposals[focusedIndex]) {
selectProposal(proposals[focusedIndex].proposal_id);
}
} else if (e.key === "Escape") {
e.preventDefault();
updateRoute({ proposalId: null });
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [proposals, focusedIndex]);
// Auto-advance focus on success
const autoAdvance = () => {
let nextIndex = -1;
for (let i = focusedIndex + 1; i < proposals.length; i++) {
if (proposals[i].state === "pending" || domain === "math") {
nextIndex = i;
break;
}
}
if (nextIndex === -1) {
for (let i = 0; i < focusedIndex; i++) {
if (proposals[i].state === "pending" || domain === "math") {
nextIndex = i;
break;
}
}
}
if (nextIndex !== -1) {
setFocusedIndex(nextIndex);
selectProposal(proposals[nextIndex].proposal_id);
} else {
updateRoute({ proposalId: null });
}
};
const isLoading = domain === "math" ? mathProposalsQuery.isLoading : cognitionProposalsQuery.isLoading;
const isError = domain === "math" ? mathProposalsQuery.isError : cognitionProposalsQuery.isError;
const error = domain === "math" ? mathProposalsQuery.error : cognitionProposalsQuery.error;
if (isLoading) {
return <LoadingState label="Loading proposal queue..." />;
}
if (proposalsQuery.isError) {
if (isError) {
return (
<ErrorState
whatFailed={errorMessage(proposalsQuery.error)}
whatFailed={errorMessage(error)}
mutationStatus="No proposal mutation occurred."
reproducer="curl /proposals"
reproducer={`curl /${domain === "math" ? "math-" : ""}proposals`}
retrySafety="Retry: safe"
/>
);
}
const detailQuery = domain === "math" ? mathDetailQuery : cognitionDetailQuery;
return (
<div className="grid h-full min-h-0 gap-4 xl:grid-cols-[minmax(34rem,0.95fr)_minmax(32rem,1.05fr)]">
<section className="grid min-h-0 content-start gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4">
<h1 className="m-0 text-base font-semibold text-[var(--color-text-primary)]">Proposal Queue</h1>
{/* Domain Selector Tabs */}
<div className="flex bg-[var(--color-surface-inset)] p-0.5 rounded border border-[var(--color-border-subtle)]">
<button
onClick={() => changeDomain("math")}
className={`px-3 py-1 rounded text-xs font-semibold transition-all ${
domain === "math"
? "bg-[var(--color-surface-raised)] text-[var(--color-text-primary)] shadow-sm"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]"
}`}
>
Math Corridor
</button>
<button
onClick={() => changeDomain("cognition")}
className={`px-3 py-1 rounded text-xs font-semibold transition-all ${
domain === "cognition"
? "bg-[var(--color-surface-raised)] text-[var(--color-text-primary)] shadow-sm"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]"
}`}
>
Cognition Queue
</button>
</div>
</div>
{domain === "cognition" && (
<div className="flex flex-wrap gap-2" role="group" aria-label="Proposal state filter">
{filters.map((state) => (
<Button
@ -96,23 +271,29 @@ export function ProposalsRoute() {
</Button>
))}
</div>
)}
</div>
{proposals.length === 0 ? (
<EmptyState
statement="No proposals match this queue view."
nextAction={{ kind: "cli", command: "core teaching proposals --state pending" }}
statement={domain === "math" ? "No math proposals match this queue view." : "No proposals match this queue view."}
nextAction={{
kind: "cli",
command: domain === "math" ? "core eval gsm8k_math" : "core teaching proposals --state pending",
}}
/>
) : (
<ProposalTable
proposals={proposals}
selectedProposalId={selectedProposalId}
focusedProposalId={focusedProposalId}
onSelect={selectProposal}
/>
)}
</section>
<section className="min-h-0 overflow-y-auto">
<section className="min-h-0 overflow-y-auto pr-1">
{!selectedProposalId ? (
<EmptyState
statement="Select a proposal to inspect replay evidence, chain records, and provenance."
@ -124,18 +305,147 @@ export function ProposalsRoute() {
<ErrorState
whatFailed={errorMessage(detailQuery.error)}
mutationStatus="No proposal mutation occurred."
reproducer={`curl /proposals/${selectedProposalId}`}
reproducer={`curl /${domain === "math" ? "math-" : ""}proposals/${selectedProposalId}`}
retrySafety="Retry: safe"
/>
) : detailQuery.data ? (
domain === "math" ? (
<MathProposalDetailView
proposal={detailQuery.data as MathProposalDetail}
state="pending"
replayEquivalent={true}
onSuccess={autoAdvance}
onDefer={autoAdvance}
/>
) : (
<div className="grid gap-4">
<ProposalSummaryCard proposal={detailQuery.data} />
<ReplayEvidenceCard proposal={detailQuery.data} />
<ProposalChainViewer proposal={detailQuery.data} />
<ProposalProvenanceViewer proposal={detailQuery.data} />
<ProposalSummaryCard proposal={detailQuery.data as any} />
<ReplayEvidenceCard proposal={detailQuery.data as any} />
<ProposalChainViewer proposal={detailQuery.data as any} />
<ProposalProvenanceViewer proposal={detailQuery.data as any} />
</div>
)
) : null}
</section>
</div>
);
}
interface MathProposalDetailViewProps {
proposal: MathProposalDetail;
state: string;
replayEquivalent: boolean | null;
onSuccess: () => void;
onDefer: () => void;
}
function MathProposalDetailView({
proposal,
state,
replayEquivalent,
onSuccess,
onDefer,
}: MathProposalDetailViewProps) {
const steps = proposal.reasoning_trace_steps || [];
const hashes = proposal.evidence_hashes || [];
return (
<div className="grid gap-4">
{/* Summary Card */}
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="m-0 font-mono text-sm font-semibold text-[var(--color-text-primary)]">
{proposal.proposal_id}
</h2>
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-[var(--color-state-neutral-bg)] border border-[var(--color-state-neutral-border)] text-[var(--color-state-neutral-text)]">
{proposal.shape_category}
</span>
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-[var(--color-state-success-bg)] border border-[var(--color-state-success-border)] text-[var(--color-state-success-text)] font-mono">
math_contemplation
</span>
</div>
<p className="mt-3 text-sm text-[var(--color-text-secondary)]">
Proposed change: <strong className="text-[var(--color-text-primary)] font-mono">{proposal.proposed_change_kind}</strong>
</p>
<dl className="mt-4 grid grid-cols-2 gap-3 text-xs">
<div>
<dt className="text-[var(--color-text-muted)]">Structural Commonality</dt>
<dd className="m-0 text-[var(--color-text-primary)] font-medium mt-0.5">{proposal.structural_commonality}</dd>
</div>
<div>
<dt className="text-[var(--color-text-muted)]">Replay Equivalence Hash</dt>
<dd className="m-0 text-[var(--color-text-primary)] font-mono truncate mt-0.5" title={proposal.replay_equivalence_hash}>
{proposal.replay_equivalence_hash}
</dd>
</div>
</dl>
</section>
{/* Wrong Zero Assertion Card */}
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
<AlertTriangle size={15} className="text-[var(--color-state-warning-text)]" />
Wrong Zero Assertion
</h3>
<p className="mt-2.5 text-xs font-mono text-[var(--color-state-warning-text)] bg-[var(--color-surface-inset)] p-3 rounded border border-[var(--color-border-subtle)] whitespace-pre-wrap leading-relaxed">
{proposal.wrong_zero_assertion}
</p>
</section>
{/* Proposed Change Payload Card */}
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)] mb-3">Proposed Change Payload</h3>
<pre className="p-3 bg-[var(--color-surface-inset)] rounded border border-[var(--color-border-subtle)] font-mono text-xs text-[var(--color-text-primary)] overflow-x-auto">
{JSON.stringify(proposal.proposed_change_payload, null, 2)}
</pre>
</section>
{/* Reasoning Trace Steps */}
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)] mb-3">
Reasoning Steps ({steps.length})
</h3>
<div className="grid gap-3 max-h-[30rem] overflow-y-auto pr-1">
{steps.map((step: MathReasoningStep) => (
<div key={step.step_index} className="p-3 bg-[var(--color-surface-inset)] rounded border border-[var(--color-border-subtle)] text-xs">
<div className="flex items-center justify-between mb-1.5 font-medium text-[var(--color-text-primary)]">
<span>Step {step.step_index}: <span className="text-[var(--color-link)] font-mono">{step.step_kind}</span></span>
<span className="text-[var(--color-text-muted)] font-mono">[{step.input_pointers?.join(", ")}]</span>
</div>
<p className="m-0 text-[var(--color-text-primary)] font-semibold mb-1">
{step.claim}
</p>
<p className="m-0 text-[var(--color-text-secondary)] italic">
{step.justification}
</p>
</div>
))}
</div>
</section>
{/* Evidence Hashes */}
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)] mb-3">Evidence Hashes</h3>
<div className="grid gap-1.5">
{hashes.map((hash: string, i: number) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs text-[var(--color-text-muted)] font-mono">#{i + 1}</span>
<code className="bg-[var(--color-surface-inset)] px-2 py-1 rounded font-mono text-xs text-[var(--color-text-primary)] truncate flex-1">
{hash}
</code>
</div>
))}
</div>
</section>
{/* Ratification Command Panel */}
<RatificationCommandPanel
proposal={proposal}
state={state}
replayEquivalent={replayEquivalent}
onSuccess={onSuccess}
onDefer={onDefer}
/>
</div>
);
}

View file

@ -0,0 +1,353 @@
import React, { useState, useEffect } from "react";
import { Check, X, Clock, Terminal, AlertTriangle } from "lucide-react";
import { useMathRatify, useMathReject, useMathDefer } from "../../api/queries";
import type { MathProposalDetail } from "../../types/api";
import { Button } from "../../design/components/primitives/Button";
import { copyText } from "../../design/lib";
interface RatificationCommandPanelProps {
proposal: MathProposalDetail;
state: string;
replayEquivalent: boolean | null;
onSuccess?: () => void;
onDefer?: () => void;
}
const CATEGORY_ALLOWLISTS: Record<string, string[]> = {
LexicalClaim: ["drain_token"],
FrameClaim: ["increment_frame", "decrement_frame", "transfer_frame", "remainder_frame"],
CompositionClaim: ["multiplicative_composition", "additive_composition", "subtractive_composition"],
};
export function RatificationCommandPanel({
proposal,
state,
replayEquivalent,
onSuccess,
onDefer,
}: RatificationCommandPanelProps) {
const handlerName = proposal.handler_name ?? "";
const isPending = state === "pending";
const isReplayEquivalent = replayEquivalent === true;
const isSupportedHandler = ["LexicalClaim", "FrameClaim", "CompositionClaim"].includes(handlerName);
const isEnabled = isPending && isReplayEquivalent && isSupportedHandler;
const allowedCategories = CATEGORY_ALLOWLISTS[handlerName] || [];
const [selectedCategory, setSelectedCategory] = useState("");
const [selectedPolarity, setSelectedPolarity] = useState("affirms");
const [showNoteInput, setShowNoteInput] = useState(false);
const [note, setNote] = useState("");
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [statusType, setStatusType] = useState<"success" | "error" | "info" | null>(null);
const ratifyMutation = useMathRatify();
const rejectMutation = useMathReject();
const deferMutation = useMathDefer();
// Reset states when proposal changes
useEffect(() => {
if (allowedCategories.length > 0) {
setSelectedCategory(allowedCategories[0]);
} else {
setSelectedCategory("");
}
setSelectedPolarity("affirms");
setShowNoteInput(false);
setNote("");
setStatusMessage(null);
setStatusType(null);
}, [proposal.proposal_id, handlerName]);
const handleRatify = async () => {
if (!isEnabled) {
if (!isPending) {
setStatusMessage(`Cannot ratify: proposal state is ${state}`);
setStatusType("error");
} else if (!isReplayEquivalent) {
setStatusMessage("Cannot ratify: proposal is not replay equivalent");
setStatusType("error");
} else {
setStatusMessage("Cannot ratify: unsupported handler name");
setStatusType("error");
}
return;
}
setStatusMessage("Executing ratification...");
setStatusType("info");
ratifyMutation.mutate(
{
proposalId: proposal.proposal_id,
category: selectedCategory || undefined,
polarity: handlerName !== "LexicalClaim" ? selectedPolarity : undefined,
dryRun: false,
},
{
onSuccess: (result) => {
if (result.applied) {
setStatusMessage(`Ratification succeeded: ${result.message}`);
setStatusType("success");
setTimeout(() => {
if (onSuccess) onSuccess();
}, 800);
} else {
setStatusMessage(`Dry run validation routed but not applied: ${result.message}`);
setStatusType("info");
}
},
onError: (err) => {
setStatusMessage(err.message);
setStatusType("error");
},
}
);
};
const handleReject = async () => {
setStatusMessage("Recording rejection...");
setStatusType("info");
rejectMutation.mutate(
{
proposalId: proposal.proposal_id,
note: note || undefined,
},
{
onSuccess: () => {
setStatusMessage("Proposal rejected successfully");
setStatusType("success");
setShowNoteInput(false);
setNote("");
setTimeout(() => {
if (onSuccess) onSuccess();
}, 800);
},
onError: (err) => {
setStatusMessage(err.message);
setStatusType("error");
},
}
);
};
const handleDefer = async () => {
setStatusMessage("Recording deferral...");
setStatusType("info");
deferMutation.mutate(
{
proposalId: proposal.proposal_id,
},
{
onSuccess: () => {
setStatusMessage("Proposal deferred successfully");
setStatusType("success");
setTimeout(() => {
if (onDefer) onDefer();
}, 800);
},
onError: (err) => {
setStatusMessage(err.message);
setStatusType("error");
},
}
);
};
const handleCopyCli = async () => {
if (proposal.suggested_ratify_cli) {
await copyText(proposal.suggested_ratify_cli);
setStatusMessage("Suggested CLI command copied to clipboard");
setStatusType("success");
setTimeout(() => {
setStatusMessage(null);
setStatusType(null);
}, 3000);
} else {
setStatusMessage("No suggested CLI command available");
setStatusType("error");
}
};
// Keyboard shortcut listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// If typing in input, don't trigger global shortcuts
if (document.activeElement?.tagName === "INPUT" || document.activeElement?.tagName === "TEXTAREA") {
if (e.key === "Escape") {
e.preventDefault();
setShowNoteInput(false);
}
return;
}
if (e.key === "r") {
e.preventDefault();
handleRatify();
} else if (e.key === "x") {
e.preventDefault();
setShowNoteInput(true);
} else if (e.key === "d") {
e.preventDefault();
handleDefer();
} else if (e.key === "y") {
e.preventDefault();
handleCopyCli();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [proposal.proposal_id, isEnabled, selectedCategory, selectedPolarity, note, state, replayEquivalent]);
const handleNoteKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleReject();
}
};
return (
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4 shadow-panel mt-4 transition-all">
<div className="flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-2 mb-3">
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
<Terminal size={16} className="text-[var(--color-text-secondary)]" />
Ratification Corridor
</h3>
<span className="text-xs font-mono text-[var(--color-text-muted)] bg-[var(--color-surface-inset)] px-2 py-0.5 rounded">
{handlerName || "No handler"}
</span>
</div>
{!isEnabled ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-state-warning-text)] bg-[var(--color-state-warning-bg)] border border-[var(--color-state-warning-border)] p-3 rounded-md mb-2">
<AlertTriangle size={16} className="shrink-0" />
<div>
<strong>Corridor Gated:</strong>{" "}
{!isPending
? `This proposal is already ${state}.`
: !isReplayEquivalent
? "Replay verification failed or is not equivalent. Ratification disabled."
: !isSupportedHandler
? `Ratification handler '${handlerName}' not admitted.`
: "Preconditions failed."}
</div>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 mb-4">
<div>
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">
Allowlisted Category
</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-full bg-[var(--color-surface-inset)] border border-[var(--color-border-subtle)] rounded-md px-3 py-1.5 text-sm text-[var(--color-text-primary)] focus:border-[var(--color-focus-ring)] focus:outline-none"
>
{allowedCategories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>
{handlerName !== "LexicalClaim" && (
<div>
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">
Polarity Mapping
</label>
<select
value={selectedPolarity}
onChange={(e) => setSelectedPolarity(e.target.value)}
className="w-full bg-[var(--color-surface-inset)] border border-[var(--color-border-subtle)] rounded-md px-3 py-1.5 text-sm text-[var(--color-text-primary)] focus:border-[var(--color-focus-ring)] focus:outline-none"
>
<option value="affirms">affirms</option>
<option value="falsifies">falsifies</option>
</select>
</div>
)}
</div>
)}
{showNoteInput && isEnabled && (
<div className="bg-[var(--color-surface-inset)] p-3 border border-[var(--color-border-subtle)] rounded-md mb-3 transition-all">
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">
Rejection Note (Enter to commit, Esc to cancel)
</label>
<div className="flex gap-2">
<input
type="text"
autoFocus
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={handleNoteKeyDown}
placeholder="e.g. unexpected slot constraints, wrong zero hazard"
className="flex-1 bg-[var(--color-surface-base)] border border-[var(--color-border-subtle)] rounded-md px-3 py-1 text-sm text-[var(--color-text-primary)] focus:border-[var(--color-focus-ring)] focus:outline-none"
/>
<Button onClick={handleReject} variant="quiet" className="text-[var(--color-review-rejected)]">
Reject
</Button>
<Button onClick={() => setShowNoteInput(false)} variant="quiet">
Cancel
</Button>
</div>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<div className="flex flex-wrap gap-2">
<Button
onClick={handleRatify}
disabled={!isEnabled || ratifyMutation.isPending}
variant={isEnabled ? "primary" : "quiet"}
title="Ratify (r)"
>
<Check size={14} className="mr-1" />
Ratify <span className="text-xs opacity-60 ml-1">(r)</span>
</Button>
<Button
onClick={() => setShowNoteInput(true)}
disabled={!isEnabled || rejectMutation.isPending}
variant="quiet"
title="Reject (x)"
>
<X size={14} className="mr-1" />
Reject <span className="text-xs opacity-60 ml-1">(x)</span>
</Button>
<Button
onClick={handleDefer}
disabled={!isEnabled || deferMutation.isPending}
variant="quiet"
title="Defer (d)"
>
<Clock size={14} className="mr-1" />
Defer <span className="text-xs opacity-60 ml-1">(d)</span>
</Button>
</div>
<Button onClick={handleCopyCli} variant="quiet" title="Copy Suggested CLI (y)">
Copy CLI <span className="text-xs opacity-60 ml-1">(y)</span>
</Button>
</div>
{statusMessage && (
<div
className={`mt-3 p-2.5 rounded-md text-xs font-mono border ${
statusType === "success"
? "bg-[var(--color-state-success-bg)] border-[var(--color-state-success-border)] text-[var(--color-state-success-text)]"
: statusType === "error"
? "bg-[var(--color-state-danger-bg)] border-[var(--color-state-danger-border)] text-[var(--color-state-danger-text)]"
: "bg-[var(--color-state-info-bg)] border-[var(--color-state-info-border)] text-[var(--color-state-info-text)]"
}`}
>
{statusMessage}
</div>
)}
</section>
);
}

View file

@ -183,3 +183,45 @@ export interface ApiError {
}
export type ApiResponse<T> = ApiOk<T> | ApiError;
export interface MathReasoningStep {
step_index: number;
step_kind: string;
claim: string;
justification: string;
input_pointers: string[];
output_payload: unknown;
}
export interface MathProposalSummary {
proposal_id: string;
domain: "math";
shape_category: string;
proposed_change_kind: string;
structural_commonality: string;
evidence_count: number;
replay_equivalence_hash: string;
}
export interface MathProposalDetail extends MathProposalSummary {
wrong_zero_assertion: string;
proposed_change_payload: unknown;
reasoning_trace_id: string;
reasoning_trace_steps: MathReasoningStep[];
evidence_hashes: string[];
handler_name: string | null;
suggested_ratify_cli: string | null;
}
export interface MathRatifyResult {
proposal_id: string;
change_kind: string;
handler_name: string;
routing_status: "routed" | "not_implemented";
message: string;
suggested_cli: string | null;
applied: boolean;
target_path: string | null;
evidence_hash: string | null;
}

View file

@ -33,6 +33,36 @@ class ApiResponse:
class WorkbenchApi:
def __init__(self, telemetry_sink: Any | None = None) -> None:
self._telemetry_sink = telemetry_sink
def attach_telemetry_sink(self, sink: Any | None) -> None:
self._telemetry_sink = sink
def _emit_operator_telemetry(
self,
event_name: str,
proposal_id: str,
outcome: str | None = None,
handler: str | None = None,
note: str | None = None,
) -> None:
if self._telemetry_sink is None:
return
payload: dict[str, Any] = {
"event": event_name,
"proposal_id": proposal_id,
"ratifier_kind": "workbench",
}
if handler is not None:
payload["handler"] = handler
if outcome is not None:
payload["outcome"] = outcome
if note is not None:
payload["note"] = note
line = json.dumps(payload, sort_keys=True, separators=(",", ":"))
self._telemetry_sink.emit(line)
def handle(self, method: str, raw_path: str, body: bytes = b"") -> ApiResponse:
parsed = urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
@ -42,7 +72,11 @@ class WorkbenchApi:
except json.JSONDecodeError as exc:
return ApiResponse(400, error("bad_request", "invalid JSON body", detail=str(exc)))
except ValueError as exc:
return ApiResponse(400, error("bad_request", str(exc)))
status = 400
msg = str(exc)
if "already ratified" in msg.lower():
status = 409
return ApiResponse(status, error("bad_request", msg))
except FileNotFoundError as exc:
missing = str(exc) or "resource"
return ApiResponse(404, error("not_found", f"not found: {missing}"))
@ -79,7 +113,13 @@ class WorkbenchApi:
return ApiResponse(200, ok({"items": readers.list_math_proposals()}))
if method == "POST" and path.endswith("/ratify") and path.startswith("/math-proposals/"):
proposal_id = unquote(path.removeprefix("/math-proposals/").removesuffix("/ratify"))
return self._math_ratify(proposal_id)
return self._math_ratify(proposal_id, body)
if method == "POST" and path.endswith("/reject") and path.startswith("/math-proposals/"):
proposal_id = unquote(path.removeprefix("/math-proposals/").removesuffix("/reject"))
return self._math_reject(proposal_id, body)
if method == "POST" and path.endswith("/defer") and path.startswith("/math-proposals/"):
proposal_id = unquote(path.removeprefix("/math-proposals/").removesuffix("/defer"))
return self._math_defer(proposal_id)
if method == "GET" and path.startswith("/math-proposals/"):
proposal_id = unquote(path.removeprefix("/math-proposals/"))
return ApiResponse(200, ok(readers.read_math_proposal(proposal_id)))
@ -111,14 +151,104 @@ class WorkbenchApi:
return ApiResponse(501, error("unsupported", "route is deferred beyond W-026"))
return ApiResponse(404, error("not_found", f"route not found: {method} {path}"))
def _math_ratify(self, proposal_id: str) -> ApiResponse:
"""Route ratification by change_kind; 501 for unimplemented handlers."""
def _math_ratify(self, proposal_id: str, body: bytes) -> ApiResponse:
"""Route ratification by change_kind; in-process execution with allowlist checks."""
category: str | None = None
polarity: str | None = None
dry_run: bool = False
if body:
try:
result: MathRatifyResult = readers.ratify_math_proposal(proposal_id)
req = json.loads(body.decode("utf-8") or "{}")
if isinstance(req, dict):
category = req.get("category")
polarity = req.get("polarity")
dry_run = bool(req.get("dry_run", False))
except Exception as exc:
return ApiResponse(400, error("bad_request", "invalid JSON body", detail=str(exc)))
import getpass
reviewer = getpass.getuser()
try:
result: MathRatifyResult = readers.ratify_math_proposal(
proposal_id,
category=category,
polarity=polarity,
reviewer=reviewer,
dry_run=dry_run,
)
except NotImplementedError as exc:
return ApiResponse(501, error("unsupported", str(exc)))
except (ValueError, FileNotFoundError) as exc:
msg = str(exc)
handler = "unknown"
try:
prop = readers.read_math_proposal(proposal_id)
handler = prop.handler_name or "unknown"
except Exception:
pass
status_code = 400
exc_class_name = exc.__class__.__name__
if exc_class_name == "AlreadyRatified" or "already ratified" in msg.lower():
status_code = 409
self._emit_operator_telemetry(
event_name="operator_ratify",
proposal_id=proposal_id,
outcome="rejected_precondition",
handler=handler,
)
return ApiResponse(status_code, error("bad_request", msg))
except Exception as exc:
return ApiResponse(500, error("runtime_unavailable", f"internal error: {exc}"))
if result.applied:
self._emit_operator_telemetry(
event_name="operator_ratify",
proposal_id=proposal_id,
outcome="applied",
handler=result.handler_name,
)
return ApiResponse(200, ok(result))
def _math_reject(self, proposal_id: str, body: bytes) -> ApiResponse:
note: str = ""
if body:
try:
req = json.loads(body.decode("utf-8") or "{}")
if isinstance(req, dict):
note = str(req.get("note", ""))
except Exception as exc:
return ApiResponse(400, error("bad_request", "invalid JSON body", detail=str(exc)))
try:
prop = readers.read_math_proposal(proposal_id)
handler = prop.handler_name or "unknown"
except FileNotFoundError as exc:
return ApiResponse(404, error("not_found", str(exc)))
self._emit_operator_telemetry(
event_name="operator_reject",
proposal_id=proposal_id,
handler=handler,
note=note,
)
return ApiResponse(200, ok({"proposal_id": proposal_id, "rejected": True}))
def _math_defer(self, proposal_id: str) -> ApiResponse:
try:
prop = readers.read_math_proposal(proposal_id)
handler = prop.handler_name or "unknown"
except FileNotFoundError as exc:
return ApiResponse(404, error("not_found", str(exc)))
self._emit_operator_telemetry(
event_name="operator_defer",
proposal_id=proposal_id,
handler=handler,
)
return ApiResponse(200, ok({"proposal_id": proposal_id, "deferred": True}))
def _chat_turn(self, body: bytes) -> ApiResponse:
"""Execute one live runtime turn.

View file

@ -328,7 +328,8 @@ def _math_proposal_summary(record: dict[str, Any]) -> MathProposalSummary:
def list_math_proposals(*, jsonl_path: Path | None = None) -> list[MathProposalSummary]:
path = jsonl_path or MATH_PROPOSALS_JSONL
records = _load_math_proposals_raw(path)
return [_math_proposal_summary(r) for r in records]
return [_math_proposal_summary(r) for r in records if r.get("domain") == "math"]
def _math_trace_steps_from_record(record: dict[str, Any]) -> list[MathReasoningStep]:
@ -376,6 +377,9 @@ def read_math_proposal(
record = next((r for r in records if r.get("proposal_id") == proposal_id), None)
if record is None:
raise FileNotFoundError(proposal_id)
if record.get("domain") != "math":
raise ValueError(f"Partition isolation violation: proposal domain must be 'math', got {record.get('domain')!r}")
change_kind = str(record.get("proposed_change_kind", ""))
handler_name = _HANDLER_DISPATCH.get(change_kind)
@ -435,23 +439,25 @@ def read_math_proposal(
def ratify_math_proposal(
proposal_id: str,
*,
category: str | None = None,
polarity: str | None = None,
reviewer: str | None = None,
dry_run: bool = False,
jsonl_path: Path | None = None,
) -> MathRatifyResult:
"""Dispatch ratification by change_kind; fail loudly for unimplemented handlers.
"""Dispatch ratification by change_kind.
ADR-0160 "Proposal before mutation" doctrine: this function validates
routing and returns the handler name + suggested CLI without applying
the change. Mutation requires an explicit operator action outside the
workbench (e.g. calling apply_lexical_claim() directly).
Raises FileNotFoundError if proposal_id not found.
Raises NotImplementedError with a clear message for unhandled change_kinds.
If dry_run is False and category is provided, this applies the ratification mutation in-process.
Otherwise, it validates routing and returns the handler name + suggested CLI.
"""
path = jsonl_path or MATH_PROPOSALS_JSONL
records = _load_math_proposals_raw(path)
record = next((r for r in records if r.get("proposal_id") == proposal_id), None)
if record is None:
raise FileNotFoundError(proposal_id)
if record.get("domain") != "math":
raise ValueError(f"Partition isolation violation: proposal domain must be 'math', got {record.get('domain')!r}")
change_kind = str(record.get("proposed_change_kind", ""))
handler_name = _HANDLER_DISPATCH.get(change_kind)
@ -483,6 +489,7 @@ def ratify_math_proposal(
f"polarity='affirms', reviewer='<you>')"
)
if dry_run or category is None:
return MathRatifyResult(
proposal_id=proposal_id,
change_kind=change_kind,
@ -490,8 +497,88 @@ def ratify_math_proposal(
routing_status="routed",
message=f"routed to {handler_name} handler",
suggested_cli=suggested_cli,
applied=False,
)
# In-process application
from teaching.math_contemplation_proposal import from_jsonl_record
proposal = from_jsonl_record(record)
if not proposal.evidence_pointers:
raise ValueError(f"Proposal {proposal_id} has no evidence pointers")
claim = proposal.evidence_pointers[0]
import getpass
effective_reviewer = reviewer or getpass.getuser()
if handler_name == "LexicalClaim":
from teaching.math_lexical_ratification import apply_lexical_claim
receipt = apply_lexical_claim(
claim=claim,
category=category,
reviewer=effective_reviewer,
ratifier_kind="workbench",
)
return MathRatifyResult(
proposal_id=proposal_id,
change_kind=change_kind,
handler_name=handler_name,
routing_status="routed",
message=f"Applied LexicalClaim to {receipt.target_file}",
suggested_cli=suggested_cli,
applied=True,
target_path=receipt.target_file,
evidence_hash=receipt.evidence_hash,
)
elif handler_name == "FrameClaim":
from teaching.math_frame_ratification import apply_frame_claim
if not polarity:
raise ValueError("Polarity is required for FrameClaim ratification")
receipt = apply_frame_claim(
claim=claim,
frame_category=category,
polarity=polarity,
reviewer=effective_reviewer,
ratifier_kind="workbench",
)
return MathRatifyResult(
proposal_id=proposal_id,
change_kind=change_kind,
handler_name=handler_name,
routing_status="routed",
message=f"Applied FrameClaim to {receipt.target_file}",
suggested_cli=suggested_cli,
applied=True,
target_path=receipt.target_file,
evidence_hash=receipt.evidence_hash,
)
elif handler_name == "CompositionClaim":
from teaching.math_composition_ratification import apply_composition_claim
if not polarity:
raise ValueError("Polarity is required for CompositionClaim ratification")
receipt = apply_composition_claim(
claim=claim,
composition_category=category,
polarity=polarity,
reviewer=effective_reviewer,
ratifier_kind="workbench",
)
return MathRatifyResult(
proposal_id=proposal_id,
change_kind=change_kind,
handler_name=handler_name,
routing_status="routed",
message=f"Applied CompositionClaim to {receipt.target_file}",
suggested_cli=suggested_cli,
applied=True,
target_path=receipt.target_file,
evidence_hash=receipt.evidence_hash,
)
else:
raise NotImplementedError(f"handler {handler_name} application not implemented")
def run_safe_eval_lane(
lane_name: str,

View file

@ -243,3 +243,6 @@ class MathRatifyResult:
routing_status: Literal["routed", "not_implemented"]
message: str
suggested_cli: str | None = None
applied: bool = False
target_path: str | None = None
evidence_hash: str | None = None

View file

@ -34,6 +34,30 @@ class WorkbenchRequestHandler(BaseHTTPRequestHandler):
)
def _handle(self) -> None:
host = self.headers.get("Host", "")
origin = self.headers.get("Origin", "")
host_name = host.split(":")[0] if ":" in host else host
if host_name not in {"127.0.0.1", "localhost"}:
payload = json.dumps({"ok": False, "error": "CORS check failed: Host is non-local"}).encode("utf-8")
self.send_response(400)
self._send_common_headers(len(payload))
self.end_headers()
self.wfile.write(payload)
return
if origin:
import urllib.parse
parsed_origin = urllib.parse.urlparse(origin)
origin_host = parsed_origin.hostname
if origin_host not in {"127.0.0.1", "localhost"}:
payload = json.dumps({"ok": False, "error": "CORS check failed: Origin is non-local"}).encode("utf-8")
self.send_response(400)
self._send_common_headers(len(payload))
self.end_headers()
self.wfile.write(payload)
return
try:
length = max(0, int(self.headers.get("Content-Length") or "0"))
except ValueError: