From d9d338416ab277f55deaf8496f827cd17260cb87 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Sat, 23 May 2026 08:53:22 +0900 Subject: [PATCH] feat(#62): IMP-46 cache fingerprint forwarding u1~u4 (router kwarg + step12 forward + 8 scenarios) --- src/phase_z2_ai_fallback/router.py | 8 +- src/phase_z2_ai_fallback/step12.py | 1 + tests/phase_z2_ai_fallback/test_router.py | 93 ++++++++++++++++++-- tests/phase_z2_ai_fallback/test_step12.py | 102 ++++++++++++++++++++++ 4 files changed, 197 insertions(+), 7 deletions(-) diff --git a/src/phase_z2_ai_fallback/router.py b/src/phase_z2_ai_fallback/router.py index fc1ecd7..2f5c5fe 100644 --- a/src/phase_z2_ai_fallback/router.py +++ b/src/phase_z2_ai_fallback/router.py @@ -50,6 +50,7 @@ def route_ai_fallback( internal_region: dict[str, Any], mdx_text: str, client: AiFallbackClient | None = None, + fingerprints: dict | None = None, ) -> AiFallbackProposal | None: """Route a fallback request through cache → prompt → client → validate. @@ -57,13 +58,18 @@ def route_ai_fallback( not ``ai_adaptation_required`` — both gates short-circuit BEFORE any prompt/client work, so the normal-path AI call count stays at 0 (PZ-1). + + ``fingerprints`` is forwarded into ``read_proposal`` so that + contract / partial / catalog SHA mismatches invalidate stale cache + entries (IMP-46 #62 Axis R). When ``None`` the cache layer skips + fingerprint comparison (legacy behaviour). """ if not settings.ai_fallback_enabled: return None route = v4_result.get("route") or v4_result.get("imp05_route_hint") if route != V4_ROUTE_AI_ADAPTATION: return None - cached = read_proposal(cache_key) + cached = read_proposal(cache_key, fingerprints=fingerprints) if cached is not None: validate_proposal( cached, diff --git a/src/phase_z2_ai_fallback/step12.py b/src/phase_z2_ai_fallback/step12.py index f0df5a9..b2406ef 100644 --- a/src/phase_z2_ai_fallback/step12.py +++ b/src/phase_z2_ai_fallback/step12.py @@ -200,6 +200,7 @@ def gather_step12_ai_repair_proposals( figma_partial_json=figma_partial_json, internal_region=internal_region, mdx_text=mdx_text, + fingerprints=fingerprints, ) except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise record["ai_called"] = True diff --git a/tests/phase_z2_ai_fallback/test_router.py b/tests/phase_z2_ai_fallback/test_router.py index 671b21e..9b73dc1 100644 --- a/tests/phase_z2_ai_fallback/test_router.py +++ b/tests/phase_z2_ai_fallback/test_router.py @@ -86,7 +86,7 @@ def test_router_returns_none_when_route_not_ai_adaptation(monkeypatch): def test_router_returns_cached_when_cache_hit(monkeypatch): monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True) cached = _make_proposal() - monkeypatch.setattr(router_mod, "read_proposal", lambda key: cached) + monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: cached) client = MagicMock(spec=AiFallbackClient) result = route_ai_fallback(**_call_kwargs(), client=client) assert result is cached @@ -99,16 +99,97 @@ def test_router_validates_cached_proposal(monkeypatch): proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH, payload={"unknown_key": "x"}, ) - monkeypatch.setattr(router_mod, "read_proposal", lambda key: bad_cached) + monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: bad_cached) client = MagicMock(spec=AiFallbackClient) with pytest.raises(AiFallbackValidationError): route_ai_fallback(**_call_kwargs(), client=client) client.request_proposal.assert_not_called() +def test_router_forwards_fingerprints_and_misses_on_mismatch(monkeypatch): + """Scope: router-level (IMP-46 #62 Axis R u2). + + When caller supplies ``fingerprints`` and the cache layer returns + ``None`` (simulating a strict-equality mismatch against the stored + contract/partial/catalog SHA), the router proceeds to call the + client. The exact ``fingerprints`` dict supplied by the caller must + appear in the kwargs passed to ``read_proposal``. + """ + monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True) + captured: dict = {} + + def _spy_read_proposal(key, *, fingerprints=None): + captured["key"] = key + captured["fingerprints"] = fingerprints + return None + + monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal) + proposal = _make_proposal() + client = MagicMock(spec=AiFallbackClient) + client.request_proposal.return_value = proposal + supplied = {"contract_sha": "aaa", "partial_sha": "bbb", "catalog_sha": "ccc"} + result = route_ai_fallback( + **_call_kwargs(), client=client, fingerprints=supplied + ) + assert result is proposal + assert captured["fingerprints"] == supplied + client.request_proposal.assert_called_once() + + +def test_router_forwards_fingerprints_and_hits_on_match(monkeypatch): + """Scope: router-level (IMP-46 #62 Axis R u2). + + When caller supplies ``fingerprints`` and the cache layer returns a + cached proposal (simulating strict-equality match against stored + SHA bundle), the router short-circuits without calling the client. + The forwarded kwarg must equal the caller-supplied dict exactly. + """ + monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True) + cached = _make_proposal() + captured: dict = {} + + def _spy_read_proposal(key, *, fingerprints=None): + captured["fingerprints"] = fingerprints + return cached + + monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal) + client = MagicMock(spec=AiFallbackClient) + supplied = {"contract_sha": "xxx", "partial_sha": "yyy", "catalog_sha": "zzz"} + result = route_ai_fallback( + **_call_kwargs(), client=client, fingerprints=supplied + ) + assert result is cached + assert captured["fingerprints"] == supplied + client.request_proposal.assert_not_called() + + +def test_router_forwards_fingerprints_none_for_legacy_callers(monkeypatch): + """Scope: router-level (IMP-46 #62 Axis R u2). + + Legacy callers that omit the ``fingerprints`` kwarg must result in + ``read_proposal`` being invoked with ``fingerprints=None`` (cache + layer skips fingerprint comparison — legacy no-invalidation + behaviour preserved). + """ + monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True) + cached = _make_proposal() + captured: dict = {} + + def _spy_read_proposal(key, *, fingerprints=None): + captured["fingerprints"] = fingerprints + return cached + + monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal) + client = MagicMock(spec=AiFallbackClient) + result = route_ai_fallback(**_call_kwargs(), client=client) + assert result is cached + assert captured["fingerprints"] is None + client.request_proposal.assert_not_called() + + def test_router_calls_client_and_returns_validated_proposal(monkeypatch): monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True) - monkeypatch.setattr(router_mod, "read_proposal", lambda key: None) + monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None) proposal = _make_proposal() client = MagicMock(spec=AiFallbackClient) client.request_proposal.return_value = proposal @@ -121,7 +202,7 @@ def test_router_calls_client_and_returns_validated_proposal(monkeypatch): def test_router_propagates_validation_error(monkeypatch): monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True) - monkeypatch.setattr(router_mod, "read_proposal", lambda key: None) + monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None) bad = AiFallbackProposal( proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH, payload={"unknown_key": "x"}, @@ -134,7 +215,7 @@ def test_router_propagates_validation_error(monkeypatch): def test_router_propagates_budget_exceeded(monkeypatch): monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True) - monkeypatch.setattr(router_mod, "read_proposal", lambda key: None) + monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None) client = MagicMock(spec=AiFallbackClient) client.request_proposal.side_effect = AiFallbackBudgetExceeded("over") with pytest.raises(AiFallbackBudgetExceeded): @@ -143,7 +224,7 @@ def test_router_propagates_budget_exceeded(monkeypatch): def test_router_propagates_circuit_open(monkeypatch): monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True) - monkeypatch.setattr(router_mod, "read_proposal", lambda key: None) + monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None) client = MagicMock(spec=AiFallbackClient) client.request_proposal.side_effect = AiFallbackCircuitOpen("tripped") with pytest.raises(AiFallbackCircuitOpen): diff --git a/tests/phase_z2_ai_fallback/test_step12.py b/tests/phase_z2_ai_fallback/test_step12.py index d8ad4b3..1ad16c6 100644 --- a/tests/phase_z2_ai_fallback/test_step12.py +++ b/tests/phase_z2_ai_fallback/test_step12.py @@ -500,3 +500,105 @@ def test_production_non_provisional_reject_skipped_before_route_gate(monkeypatch assert records[0]["skip_reason"] == "not_provisional" assert records[0]["ai_called"] is False router.assert_not_called() + + +# --------------------------------------------------------------------------- +# IMP-46 u4 — Step 12 ↔ router fingerprints forwarding (integration scope) +# --------------------------------------------------------------------------- +# Locks the producer→consumer wiring added in u3: Step 12 builds the +# fingerprints dict at step12.py:179-185, stamps it onto record["fingerprints"] +# at step12.py:185, and now (post-u3) forwards the SAME object into +# route_ai_fallback via the fingerprints= kwarg at step12.py:203. These tests +# assert end-to-end forwarding (router receives exactly record["fingerprints"] +# for AI-eligible units; router untouched and record["fingerprints"] is None +# for skipped / non-AI records). + + +def test_router_receives_exactly_record_fingerprints_for_ai_eligible(monkeypatch): + """Integration scope: route_ai_fallback fingerprints kwarg == record['fingerprints'].""" + router = MagicMock(return_value=None) + monkeypatch.setattr(step12_mod, "route_ai_fallback", router) + contract = {"frame_id": "fid_123", "payload": {"k": "v"}, "sub_zones": []} + partial = {"deeper": [9, 8, 7], "shallow": "x"} + catalog_value = "c0ffee00" * 8 + recs = _call( + [_ai_unit()], + get_contract_fn=lambda _t: contract, + figma_partial_loader=lambda _t: partial, + catalog_sha_loader=lambda: catalog_value, + ) + record_fingerprints = recs[0]["fingerprints"] + router.assert_called_once() + forwarded = router.call_args.kwargs["fingerprints"] + # Strict equality: same keys, same SHA values — Step 12 producer is wired + # to the router consumer with no transformation between them. + assert forwarded == record_fingerprints + assert forwarded == { + "contract_sha": hashlib.sha256( + json.dumps(contract, sort_keys=True, ensure_ascii=False).encode("utf-8") + ).hexdigest(), + "partial_sha": hashlib.sha256( + json.dumps(partial, sort_keys=True, ensure_ascii=False).encode("utf-8") + ).hexdigest(), + "catalog_sha": catalog_value, + } + + +def test_router_fingerprints_kwarg_is_present_even_with_default_catalog(monkeypatch): + """Integration scope: fingerprints kwarg is supplied (not omitted) even when catalog_sha defaults to ''.""" + router = MagicMock(return_value=None) + monkeypatch.setattr(step12_mod, "route_ai_fallback", router) + _call([_ai_unit()]) + router.assert_called_once() + # The fingerprints kwarg must be present in the call (not relying on the + # router's default None) — proves step12.py:203 forwards explicitly. + assert "fingerprints" in router.call_args.kwargs + forwarded = router.call_args.kwargs["fingerprints"] + assert isinstance(forwarded, dict) + assert set(forwarded.keys()) == {"contract_sha", "partial_sha", "catalog_sha"} + assert forwarded["catalog_sha"] == "" # default sentinel, still forwarded + + +def test_router_not_called_and_fingerprints_none_for_non_provisional(monkeypatch): + """Integration scope: non-provisional unit → router untouched, record['fingerprints'] is None.""" + router = MagicMock(return_value=None) + monkeypatch.setattr(step12_mod, "route_ai_fallback", router) + recs = _call([FakeUnit(label="restructure", provisional=False)]) + router.assert_not_called() + assert recs[0]["ai_called"] is False + assert recs[0]["skip_reason"] == "not_provisional" + assert recs[0]["fingerprints"] is None + assert recs[0]["cache_key"] is None + + +def test_router_not_called_and_fingerprints_none_for_non_ai_route(monkeypatch): + """Integration scope: light_edit (non-AI route) → router untouched, record['fingerprints'] is None.""" + router = MagicMock(return_value=None) + monkeypatch.setattr(step12_mod, "route_ai_fallback", router) + recs = _call([FakeUnit(label="light_edit", provisional=True)]) + router.assert_not_called() + assert recs[0]["ai_called"] is False + assert recs[0]["skip_reason"] == ( + "route_not_ai_adaptation:deterministic_minor_adjustment" + ) + assert recs[0]["fingerprints"] is None + assert recs[0]["cache_key"] is None + + +def test_mixed_units_router_receives_fingerprints_only_for_ai_eligible(monkeypatch): + """Integration scope: in a mixed batch, only the AI-eligible unit forwards fingerprints to router.""" + router = MagicMock(return_value=None) + monkeypatch.setattr(step12_mod, "route_ai_fallback", router) + units = [ + FakeUnit(label="restructure", provisional=False), # not_provisional + FakeUnit(label="light_edit", provisional=True), # non-AI route + _ai_unit(), # AI-eligible + ] + recs = _call(units) + # Exactly one router invocation — the AI-eligible unit. + router.assert_called_once() + forwarded = router.call_args.kwargs["fingerprints"] + assert forwarded == recs[2]["fingerprints"] + # Skipped records carry None. + assert recs[0]["fingerprints"] is None + assert recs[1]["fingerprints"] is None