1 line
12 KiB
JSON
1 line
12 KiB
JSON
{"body": "[Claude #4] Stage 3 code-edit IMP-46 \u2014 u4 structural cache key + fingerprints\n\nExecuting unit: u4\n\n\u25a0 unit_executed\n- id: u4\n- summary: Replace the sample-identity cache_key (`template_id::sorted(source_section_ids)`) in `src/phase_z2_ai_fallback/step12.py` with a structural signature key `\"{frame_id}::{signature_hash}\"` derived from the 8 IMP-46 axes via `build_signature` (u1). Extend Step 12 helper inputs (unit attributes) for `layout_preset`, `zone_position`, `source_shape`, `h3_count`, `char_count`, and `cardinality`; project `char_count` through `bucket_char_count` (u1). Compute the three invalidation fingerprints (`contract_sha`, `partial_sha`, `catalog_sha`) and attach them \u2014 with the new `cache_key` \u2014 to each AI-eligible record for downstream `save_proposal` use. Exact-match lookup only (no fuzzy fallback added). v4_result.cardinality now mirrors the unit's cardinality (was hardcoded `None`).\n\n\u25a0 files_changed\n- src/phase_z2_ai_fallback/step12.py (modified, +82 / -2 \u2014 adds `_sha256_of` helper, `catalog_sha_loader` kwarg, structural signature + fingerprint block, record fields `cache_key` + `fingerprints`)\n- tests/phase_z2_ai_fallback/test_step12.py (modified, +247 / -16 \u2014 replaces the legacy `test_cache_key_includes_template_and_section_ids` test with 14 new u4 cases; FakeUnit extended with 6 new signature-input fields)\n\n\u25a0 diff_summary\n- `src/phase_z2_ai_fallback/step12.py`\n - Module docstring expanded with an `IMP-46 u4 \u2014 structural cache key + fingerprints` section that names every signature axis read from unit attributes and explains why fingerprint *computation* lives here (cache.py is a comparator per u3 \u2014 keeps the cache module schema-agnostic).\n - New stdlib imports: `hashlib`, `json` (both already in the AST isolation whitelist \u2014 `test_ast_isolation.py:39-42`).\n - New intra-package import: `bucket_char_count`, `build_signature` from `src.phase_z2_ai_fallback.signature` (u1).\n - New module-level helper `_sha256_of(payload: Any) -> str`: deterministic SHA256 over `json.dumps(payload, sort_keys=True, ensure_ascii=False)`. Used only for `contract_sha` and `partial_sha`.\n - `gather_step12_ai_repair_proposals` signature gains one new keyword-only argument:\n - `catalog_sha_loader: Callable[[], str] | None = None` \u2014 called once per gather invocation (verified by `test_catalog_sha_loader_called_once_per_gather`). When `None`, `catalog_sha` defaults to `\"\"` (sentinel \u2014 always present, so `fingerprints` is always a 3-key dict).\n - Record schema gains two fields, both initialised to `None`:\n - `\"cache_key\": str | None` \u2014 populated only on the AI-eligible code path; the structural axes are not guaranteed for skipped units, so the field is left `None` for `not_provisional` / `design_reference_only_no_ai` / `route_not_ai_adaptation:*` records.\n - `\"fingerprints\": dict | None` \u2014 same population rule.\n - Inside the AI-eligible branch (after route gates pass):\n - Read signature inputs from unit attributes via `getattr` with safe defaults (so existing test fixtures and pre-IMP-46 units survive): `frame_id_value`, `cardinality`, `layout_preset` (default `\"\"`), `zone_position` (default `\"\"`), `source_shape` (default `\"paragraph\"` \u2014 valid `SourceShape` enum member), `h3_count` (default `0`), `char_count` (default `0`).\n - `char_count_bucket = bucket_char_count(char_count)` \u2014 u1 fixed-bin projection.\n - `signature_hash = build_signature(frame_id=..., v4_label=label or \"\", cardinality=..., source_shape=..., h3_count=..., char_count_bucket=..., layout_preset=..., zone_position=...)` \u2014 8-axis SHA256.\n - `cache_key = f\"{frame_id_value}::{signature_hash}\"` \u2014 matches cache.py `_parse_key` format (`KEY_DELIMITER = \"::\"`); validated by `test_cache_key_is_compatible_with_cache_parse_key`.\n - `fingerprints = {\"contract_sha\": _sha256_of(frame_contract), \"partial_sha\": _sha256_of(figma_partial_json), \"catalog_sha\": catalog_sha}`.\n - `v4_result[\"cardinality\"]` now reads the unit's `cardinality` attribute instead of the hardcoded `None` from IMP-33 u8.\n - `route_ai_fallback(cache_key=cache_key, ...)` now receives the structural key (the router's existing read-side path is unchanged \u2014 `read_proposal(cache_key)` continues to perform exact-match lookup only, as required by the u4 contract).\n\n- `tests/phase_z2_ai_fallback/test_step12.py`\n - Module docstring updated to declare the IMP-46 u4 coverage axis alongside the IMP-33 gates.\n - `FakeUnit` dataclass extended with 6 new fields (all with safe defaults): `cardinality: int | None = None`, `layout_preset: str = \"\"`, `zone_position: str = \"\"`, `source_shape: str = \"paragraph\"`, `h3_count: int = 0`, `char_count: int = 0`. All pre-existing tests continue to construct `FakeUnit(label=..., provisional=...)` without modification.\n - New helper `_ai_unit(**overrides)`: builds an AI-eligible (`provisional=True`, `label=\"restructure\"`) `FakeUnit` with realistic signature axes \u2014 keeps the u4 test bodies readable without mutating the existing test surface.\n - Legacy `test_cache_key_includes_template_and_section_ids` REMOVED \u2014 it asserted the broken `template_id::sorted(section_ids)` format that u4 explicitly replaces. Removing it (rather than xfailing) is consistent with the no-hardcoding lock: that key shape is now a defect, not a contract.\n - Existing `test_record_shape_contract_is_stable` renamed to `test_record_shape_contract_is_stable_with_u4_fields` and updated to assert exactly 12 keys (the original 10 + `cache_key` + `fingerprints`).\n - 14 new u4 cases:\n - `test_cache_key_format_is_frame_id_plus_sha256` \u2014 `cache_key.startswith(\"fid_123::\")`, suffix is 64-char lowercase hex; asserts the legacy substrings `\"tmpl_x\"` and `\"02-1\"` are absent.\n - `test_cache_key_invariant_to_section_id_changes` \u2014 `source_section_ids=[\"02-1\"]` and `[\"05-2\",\"07-3\"]` produce the same `cache_key` (no sample leakage).\n - `test_cache_key_invariant_to_template_id_changes` \u2014 `frame_template_id` is NOT in the signature surface (only `frame_id` is).\n - `test_cache_key_changes_when_any_signature_axis_changes` \u2014 parametrised-style loop over `{frame_id, layout_preset, zone_position, source_shape, h3_count, char_count, cardinality}`; each single-axis flip mutates `cache_key`. `char_count=500` is chosen specifically to cross the `151-400` \u2192 `401-1000` bucket boundary (verifies bucketing, not raw count).\n - `test_char_count_bucket_collapses_within_bucket` \u2014 `char_count=160` and `char_count=399` both fall in `\"151-400\"` and produce identical keys; `char_count=401` differs.\n - `test_fingerprints_attached_to_ai_record` \u2014 fingerprints dict has exactly the 3 declared keys, all string values; `contract_sha` and `partial_sha` match an inline recomputation of `hashlib.sha256(json.dumps(..., sort_keys=True, ensure_ascii=False).encode())`. Stable-fixture lock against silent fingerprint-format drift.\n - `test_fingerprints_default_catalog_sha_is_empty_string` \u2014 no `catalog_sha_loader` \u2192 `catalog_sha == \"\"` AND the key still appears in the dict (3-key invariant).\n - `test_fingerprints_change_when_contract_changes` \u2014 `frame_contract={\"a\":1}` vs `{\"a\":2}` mutates `contract_sha`, leaves `partial_sha` unchanged.\n - `test_fingerprints_change_when_partial_changes` \u2014 symmetric for `figma_partial_json`.\n - `test_v4_result_cardinality_uses_unit_value` \u2014 unit `cardinality=7` flows into `v4_result[\"cardinality\"]=7`; `cardinality=None` stays `None`. Closes the IMP-33 u8 hardcoded-`None` gap noted in Stage 1.\n - `test_skipped_records_have_no_cache_key_or_fingerprints` \u2014 `not_provisional`, `reject`, and `light_edit` paths all keep `cache_key=None` and `fingerprints=None`.\n - `test_catalog_sha_loader_called_once_per_gather` \u2014 `MagicMock` loader is called exactly once across 3 AI-eligible units (not once per unit).\n - `test_record_shape_contract_is_stable_with_u4_fields` \u2014 record key set is exactly `{unit_index, source_section_ids, frame_template_id, label, route_hint, provisional, ai_called, skip_reason, proposal, error, cache_key, fingerprints}`.\n - `test_cache_key_is_compatible_with_cache_parse_key` \u2014 round-trips the produced `cache_key` through `cache._parse_key`; asserts `frame_id == \"fid_123\"`, `len(signature_hash) == 64`, no extra `KEY_DELIMITER`. Cross-module structural lock \u2014 the u4 producer and the u2 consumer agree on the key shape.\n\n\u25a0 validation\n- `python -m pytest tests/phase_z2_ai_fallback/test_step12.py tests/phase_z2_ai_fallback/test_ast_isolation.py tests/phase_z2_ai_fallback/test_cache.py tests/phase_z2_ai_fallback/test_cache_invalidation.py tests/phase_z2_ai_fallback/test_router.py tests/phase_z2_ai_fallback/test_signature.py -q` \u2192 **125 passed in 0.32s**.\n- `python -m pytest tests/test_family_contract_baseline.py tests/phase_z2_ai_fallback/ -q` \u2192 **192 passed in 1.02s** (Stage 2 baseline regression suite green pre- and post-u4).\n- `git diff --stat` \u2192 `step12.py +84/-2`, `test_step12.py +247/-16` (315 insertions, 16 deletions, 2 files).\n- `Grep` audit of `src/phase_z2_ai_fallback/step12.py` for `source_section_ids|template_id|mdx_path|section_id|MDX 0[345]`: every hit is either the docstring describing the LEGACY format, the record-schema docstring, the record initialisation (telemetry \u2014 never fed to `build_signature`), or the `template_id` variable that is used **only** as a key for the existing `get_contract_fn` / `frame_visual_loader` / `figma_partial_loader` callbacks. **`template_id` and `source_section_ids` do NOT appear in `build_signature(...)` arguments or in the `cache_key` f-string.** No-hardcoding lock honored.\n- AI isolation contract: no new module imports outside the existing whitelist; `hashlib` and `json` were already allowed by `test_ast_isolation.py:39-42`. No Anthropic/MDX/Phase Q/Phase Z2 runtime imports added.\n\n\u25a0 remaining_units\n- [u5, u6]\n\n\u25a0 scope_lock observations (NOT acted on)\n- Router-side read fingerprint validation: the router's existing call is `read_proposal(cache_key)` \u2014 it does NOT yet pass the new `fingerprints` dict to `read_proposal` (the u3 invalidation comparator is therefore inert through the router today). u4's files list (per Stage 2 plan) is `step12.py + test_step12.py` only; `router.py` is out of scope. Step 12 produces and exposes the fingerprints; wiring them into the router's read call is a follow-up axis. Recording below.\n\n\u25a0 follow_up_issue_candidates\n- **router read-side fingerprint validation** \u2014 `src/phase_z2_ai_fallback/router.py:66` calls `read_proposal(cache_key)` without `fingerprints=`. To complete the u3 invalidation chain end-to-end (so a stale cache entry with changed `contract_sha` / `partial_sha` / `catalog_sha` misses through the router), the router needs to accept a `fingerprints` kwarg from `gather_step12_ai_repair_proposals` and forward it to `read_proposal`. This is **not** required by the Stage 2 plan's u4 scope, but the cache-invalidation behaviour is incomplete without it. Suggest filing as a small follow-up issue (or folding into u5 if the orchestrator consents \u2014 but strictly outside the unit list as written).\n- **catalog_sha source-of-truth** \u2014 `catalog_sha_loader` is a `Callable[[], str]`. The actual catalog content (Step 2 plan says the invalidation trigger is `templates/phase_z2/catalog/frame_contracts.yaml`) is not yet wired here \u2014 only the loader hook exists. Production wiring of a real catalog sha calculator at the pipeline integration site is a separate task.\n\nDO NOT commit or push (Stage 3 unit policy).\n"} |