Skip to content

Commit 6c6ac8f

Browse files
committed
feat(v7-h11): side_channels.apply RPC backend (per-family resolution + gotcha echo)
Closes the backend half of item 20 of the UI<->API wiring honesty list. Before this commit SideChannelsTab 'Apply' only wrote to local spec — the UI could not learn whether a required family pointed at columns the parquet shard didn't carry until the full verify ran. side_channels.apply: - For each family computes active/inactive + columns_present / columns_missing + human-readable reason. - Reuses verify's _side_channel_policy_gotchas helper so required- column gotchas appear in both verify and apply. - Returns active_count / inactive_count so UI can render 'applied: 3 of 5 families active' inline. Honest scope: backend only — UI Apply-button wiring to call this RPC ships in a follow-up commit. bd: cppmega-mlx-wowq 7/7 pytest + 21/21 lifecycle+side-channel regression.
1 parent 1105008 commit 6c6ac8f

3 files changed

Lines changed: 299 additions & 0 deletions

File tree

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@
5858
SideChannelPreviewParams,
5959
preview_side_channels,
6060
)
61+
from cppmega_v4.jsonrpc.side_channel_apply_method import (
62+
SideChannelApplyParams,
63+
apply_side_channels,
64+
)
6165
from cppmega_v4.jsonrpc.schema import (
6266
BuildPresetSpecsParams,
6367
CatalogExplainParams,
@@ -174,6 +178,10 @@
174178
SideChannelPreviewParams,
175179
lambda p, c: preview_side_channels(p, cache=c),
176180
),
181+
"side_channels.apply": (
182+
SideChannelApplyParams,
183+
lambda p, c: apply_side_channels(p, cache=c),
184+
),
177185
}
178186

179187

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""V7-H11: side_channels.apply RPC — verify a side-channel config.
2+
3+
The Sidebar SideChannelsTab assembles a SideChannelSpecPayload (per-
4+
family mode/embedding/fallback + inference enrichment policy + a list
5+
of available_side_channels reported by the parquet shard) and asks
6+
the backend "is this config self-consistent, and if I plumb it into
7+
verify+train, what does it actually resolve to?".
8+
9+
Before V7-H11 the Apply button only wrote to local spec; user had no
10+
backend confirmation that, e.g., a required family that asks for
11+
columns the parquet doesn't carry would fail at verify-time. This RPC
12+
runs the same gotcha-checker that verify does, plus echoes the set
13+
of families that would actually be active (mode != off + columns
14+
present in available_side_channels) so the UI can show "applied: 3 of
15+
5 families active".
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import time
21+
from typing import Any
22+
23+
from pydantic import BaseModel, ConfigDict, Field
24+
25+
from cppmega_v4.jsonrpc.cache import LRUCache, canonical_sha256
26+
from cppmega_v4.jsonrpc.schema import (
27+
GotchaPayload, SideChannelSpecPayload,
28+
)
29+
30+
31+
class SideChannelApplyParams(BaseModel):
32+
"""Request: verify a side-channel config against available columns."""
33+
34+
model_config = ConfigDict(extra="forbid")
35+
36+
side_channels: SideChannelSpecPayload
37+
available_side_channels: list[str] = Field(
38+
default_factory=lambda: ["doc_ids", "token_ids"],
39+
)
40+
41+
42+
class FamilyApplyStatus(BaseModel):
43+
"""Per-family resolution: did this family actually engage?"""
44+
45+
model_config = ConfigDict(extra="forbid")
46+
47+
family: str
48+
mode: str
49+
active: bool
50+
reason: str
51+
columns_requested: list[str]
52+
columns_present: list[str]
53+
columns_missing: list[str]
54+
55+
56+
class SideChannelApplyResult(BaseModel):
57+
"""V7-H11: side-channel apply verdict + per-family resolution."""
58+
59+
model_config = ConfigDict(extra="forbid")
60+
61+
ok: bool
62+
families: list[FamilyApplyStatus]
63+
gotchas: list[GotchaPayload]
64+
active_count: int
65+
inactive_count: int
66+
elapsed_ms: float
67+
68+
69+
def _resolve_family(
70+
name: str, policy: Any, available: frozenset[str],
71+
global_mode: str,
72+
) -> FamilyApplyStatus:
73+
requested = list(policy.columns)
74+
present = [c for c in requested if c in available]
75+
missing = [c for c in requested if c not in available]
76+
mode = policy.mode
77+
effective_required = (mode == "require") or (global_mode == "require")
78+
if mode == "off":
79+
return FamilyApplyStatus(
80+
family=name, mode=mode, active=False,
81+
reason="family mode=off",
82+
columns_requested=requested,
83+
columns_present=present, columns_missing=missing,
84+
)
85+
if not requested:
86+
return FamilyApplyStatus(
87+
family=name, mode=mode, active=False,
88+
reason="no columns declared for family",
89+
columns_requested=requested,
90+
columns_present=present, columns_missing=missing,
91+
)
92+
if not present:
93+
reason = (
94+
"required columns missing"
95+
if effective_required else
96+
"no requested columns present in shard"
97+
)
98+
return FamilyApplyStatus(
99+
family=name, mode=mode, active=False, reason=reason,
100+
columns_requested=requested,
101+
columns_present=present, columns_missing=missing,
102+
)
103+
if missing and effective_required:
104+
return FamilyApplyStatus(
105+
family=name, mode=mode, active=False,
106+
reason=f"required columns missing: {', '.join(missing)}",
107+
columns_requested=requested,
108+
columns_present=present, columns_missing=missing,
109+
)
110+
reason = (
111+
"all requested columns present"
112+
if not missing else
113+
f"partial: {len(present)}/{len(requested)} columns present"
114+
)
115+
return FamilyApplyStatus(
116+
family=name, mode=mode, active=True, reason=reason,
117+
columns_requested=requested,
118+
columns_present=present, columns_missing=missing,
119+
)
120+
121+
122+
def apply_side_channels(
123+
params: SideChannelApplyParams,
124+
*,
125+
cache: LRUCache | None = None,
126+
) -> SideChannelApplyResult:
127+
"""Verify side-channel config; report per-family resolution + gotchas."""
128+
cache_key = "side-channel-apply::" + canonical_sha256(
129+
params.model_dump(mode="json"),
130+
)
131+
if cache is not None:
132+
hit = cache.get(cache_key)
133+
if hit is not None:
134+
return hit
135+
136+
t0 = time.perf_counter()
137+
available = frozenset(params.available_side_channels)
138+
global_mode = params.side_channels.mode
139+
families: list[FamilyApplyStatus] = []
140+
for name, policy in sorted(params.side_channels.families.items()):
141+
families.append(_resolve_family(name, policy, available, global_mode))
142+
143+
# Reuse the same gotcha helper that verify uses so apply <-> verify
144+
# agree on what's an error.
145+
from cppmega_v4.jsonrpc.methods import _side_channel_policy_gotchas
146+
gotchas = _side_channel_policy_gotchas(params.side_channels, available)
147+
148+
active = sum(1 for f in families if f.active)
149+
inactive = sum(1 for f in families if not f.active)
150+
ok = not any(g.severity == "error" for g in gotchas)
151+
152+
result = SideChannelApplyResult(
153+
ok=ok,
154+
families=families,
155+
gotchas=gotchas,
156+
active_count=active,
157+
inactive_count=inactive,
158+
elapsed_ms=round((time.perf_counter() - t0) * 1000.0, 3),
159+
)
160+
if cache is not None:
161+
cache.set(cache_key, result)
162+
return result
163+
164+
165+
__all__ = [
166+
"SideChannelApplyParams", "SideChannelApplyResult",
167+
"FamilyApplyStatus", "apply_side_channels",
168+
]
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""V7-H11: side_channels.apply RPC + per-family resolution + gotcha echo."""
2+
3+
from __future__ import annotations
4+
5+
from cppmega_v4.jsonrpc.side_channel_apply_method import (
6+
SideChannelApplyParams, apply_side_channels,
7+
)
8+
from cppmega_v4.jsonrpc.schema import (
9+
FamilySpecPayload, SideChannelSpecPayload,
10+
)
11+
12+
13+
def _spec_one(family_name: str, columns: list[str],
14+
mode: str = "if_available") -> SideChannelSpecPayload:
15+
return SideChannelSpecPayload(
16+
mode="if_available",
17+
families={
18+
family_name: FamilySpecPayload(
19+
mode=mode, columns=columns,
20+
embedding="categorical", dropout=0.0,
21+
),
22+
},
23+
)
24+
25+
26+
def test_v7_h11_active_when_all_columns_present():
27+
r = apply_side_channels(SideChannelApplyParams(
28+
side_channels=_spec_one("platform", ["platform_ids"]),
29+
available_side_channels=["platform_ids", "token_ids"],
30+
))
31+
assert r.ok is True
32+
assert r.active_count == 1
33+
assert r.inactive_count == 0
34+
assert r.families[0].family == "platform"
35+
assert r.families[0].active is True
36+
assert r.families[0].columns_missing == []
37+
assert r.families[0].reason == "all requested columns present"
38+
39+
40+
def test_v7_h11_inactive_when_columns_absent_and_not_required():
41+
r = apply_side_channels(SideChannelApplyParams(
42+
side_channels=_spec_one("syntax",
43+
["token_ast_depth", "token_sibling_index"]),
44+
available_side_channels=["doc_ids", "token_ids"],
45+
))
46+
# Not required → gotcha NOT raised, but family is inactive.
47+
assert r.ok is True
48+
assert r.active_count == 0
49+
assert r.inactive_count == 1
50+
f = r.families[0]
51+
assert f.active is False
52+
assert f.columns_missing == ["token_ast_depth", "token_sibling_index"]
53+
assert "no requested columns present" in f.reason
54+
55+
56+
def test_v7_h11_required_missing_columns_produce_gotcha_and_ok_false():
57+
r = apply_side_channels(SideChannelApplyParams(
58+
side_channels=_spec_one("platform", ["platform_ids"],
59+
mode="require"),
60+
available_side_channels=["doc_ids"],
61+
))
62+
assert r.ok is False
63+
assert any(g.id == "side_channel_required_platform"
64+
for g in r.gotchas)
65+
assert r.families[0].active is False
66+
67+
68+
def test_v7_h11_partial_columns_present_active_with_reason():
69+
r = apply_side_channels(SideChannelApplyParams(
70+
side_channels=_spec_one("structure",
71+
["token_structure_ids", "token_dep_levels",
72+
"token_chunk_starts"]),
73+
available_side_channels=["token_structure_ids", "doc_ids"],
74+
))
75+
f = r.families[0]
76+
assert f.active is True
77+
assert set(f.columns_present) == {"token_structure_ids"}
78+
assert set(f.columns_missing) == {"token_dep_levels",
79+
"token_chunk_starts"}
80+
assert "partial: 1/3" in f.reason
81+
82+
83+
def test_v7_h11_mode_off_yields_inactive_without_warning():
84+
r = apply_side_channels(SideChannelApplyParams(
85+
side_channels=_spec_one("temporal_diff", ["doc_ids"], mode="off"),
86+
available_side_channels=["doc_ids"],
87+
))
88+
assert r.ok is True
89+
assert r.active_count == 0
90+
assert r.families[0].reason == "family mode=off"
91+
assert r.gotchas == []
92+
93+
94+
def test_v7_h11_dispatcher_routes_method_name():
95+
"""End-to-end: side_channels.apply method must reach the handler."""
96+
from cppmega_v4.jsonrpc.dispatcher import dispatch
97+
response = dispatch({
98+
"jsonrpc": "2.0", "id": "T1", "method": "side_channels.apply",
99+
"params": {
100+
"side_channels": {
101+
"mode": "if_available", "families": {},
102+
},
103+
"available_side_channels": ["doc_ids"],
104+
},
105+
})
106+
assert response.error is None, response.error
107+
assert response.result is not None
108+
assert response.result["ok"] is True
109+
assert response.result["families"] == []
110+
111+
112+
def test_v7_h11_dispatcher_rejects_unknown_extra_field():
113+
"""Schema is strict (extra=forbid)."""
114+
from cppmega_v4.jsonrpc.dispatcher import dispatch
115+
response = dispatch({
116+
"jsonrpc": "2.0", "id": "T2", "method": "side_channels.apply",
117+
"params": {
118+
"side_channels": {"mode": "if_available", "families": {}},
119+
"unknown_extra": 42,
120+
},
121+
})
122+
assert response.error is not None
123+
assert response.error.code == -32602 # INVALID_PARAMS

0 commit comments

Comments
 (0)