@@ -7,16 +7,55 @@ import type {
77 SideChannelMode ,
88 SideChannelState ,
99} from "@/state/spec" ;
10+ import type { RpcClient } from "@/lib/rpc" ;
1011
1112export interface SideChannelsTabProps {
1213 sideChannels : SideChannelState ;
1314 availableChannels : string [ ] ;
1415 selectedTrainChannels : string [ ] ;
1516 gotchas : GotchaState [ ] ;
17+ rpc ?: RpcClient | null ;
18+ tokenizerSource ?: string | null ;
1619 onApply : ( next : SideChannelState ) => void ;
1720 onTrainChannelsChange : ( next : string [ ] ) => void ;
1821}
1922
23+ interface TensorPreviewPayload {
24+ shape : number [ ] ;
25+ dtype : string ;
26+ sample : ( number | boolean ) [ ] ;
27+ }
28+
29+ interface SideChannelPreviewPayload {
30+ token_count : number ;
31+ prompt_ids : TensorPreviewPayload ;
32+ model_kwargs : Record < string , TensorPreviewPayload > ;
33+ side_channels : Record < string , Record < string , TensorPreviewPayload > > ;
34+ provenance : Record < string , string > ;
35+ rendered_platform_context : string ;
36+ cache_key : string ;
37+ elapsed_ms : number ;
38+ }
39+
40+ /** V7-H11: side_channels.apply backend verdict. */
41+ interface SideChannelApplyPayload {
42+ ok : boolean ;
43+ active_count : number ;
44+ inactive_count : number ;
45+ families : Array < {
46+ family : string ;
47+ mode : string ;
48+ active : boolean ;
49+ reason : string ;
50+ columns_requested : string [ ] ;
51+ columns_present : string [ ] ;
52+ columns_missing : string [ ] ;
53+ } > ;
54+ gotchas : Array < { id : string ; severity : string ; message : string ;
55+ reference ?: string } > ;
56+ elapsed_ms : number ;
57+ }
58+
2059const MODES : SideChannelMode [ ] = [ "off" , "auto" , "require" , "if_available" ] ;
2160const FALLBACKS : SideChannelFallback [ ] = [
2261 "zeros" , "unknown_id" , "drop_family" , "error" ,
@@ -32,7 +71,7 @@ type AdapterName = typeof ADAPTERS[number];
3271
3372export function SideChannelsTab ( {
3473 sideChannels, availableChannels, selectedTrainChannels, gotchas, onApply,
35- onTrainChannelsChange,
74+ onTrainChannelsChange, rpc = null , tokenizerSource = null ,
3675} : SideChannelsTabProps ) : JSX . Element {
3776 const [ draft , setDraft ] = useState < SideChannelState > ( sideChannels ) ;
3877 const [ platform , setPlatform ] = useState ( {
@@ -45,6 +84,13 @@ export function SideChannelsTab({
4584 const [ prompt , setPrompt ] = useState ( "int add(int a, int b) { return a + b; }" ) ;
4685 const [ adapter , setAdapter ] = useState < AdapterName > ( "cpp" ) ;
4786 const [ tensorPreview , setTensorPreview ] = useState < string [ ] > ( [ ] ) ;
87+ const [ previewError , setPreviewError ] = useState < string | null > ( null ) ;
88+ // V7-H11: backend-confirmed Apply verdict (per-family resolution +
89+ // gotchas + ok/inactive counts). null = not yet applied this draft.
90+ const [ applyResult , setApplyResult ] =
91+ useState < SideChannelApplyPayload | null > ( null ) ;
92+ const [ applyError , setApplyError ] = useState < string | null > ( null ) ;
93+ const [ applying , setApplying ] = useState < boolean > ( false ) ;
4894
4995 useEffect ( ( ) => setDraft ( sideChannels ) , [ sideChannels ] ) ;
5096
@@ -248,12 +294,7 @@ export function SideChannelsTab({
248294 style = { { width : "100%" , minHeight : 54 ,
249295 fontFamily : "monospace" , fontSize : 11 } } />
250296 < button data-testid = "side-channel-preview-run"
251- onClick = { ( ) => setTensorPreview ( buildTensorPreview ( {
252- sideChannels : draft ,
253- prompt,
254- platformPreview,
255- adapter,
256- } ) ) } >
297+ onClick = { ( ) => { void runPreview ( ) ; } } >
257298 Build preview
258299 </ button >
259300 < pre data-testid = "side-channel-preview" style = { preview } >
@@ -265,7 +306,9 @@ platform=${platformPreview || "unspecified"}
265306families=${ enabledFamilies . join ( "," ) || "none" } ` }
266307 </ pre >
267308 < pre data-testid = "side-channel-preview-tensors" style = { preview } >
268- { tensorPreview . length === 0 ? "not built" : tensorPreview . join ( "\n" ) }
309+ { previewError ?? (
310+ tensorPreview . length === 0 ? "not built" : tensorPreview . join ( "\n" )
311+ ) }
269312 </ pre >
270313 </ section >
271314
@@ -284,12 +327,78 @@ families=${enabledFamilies.join(",") || "none"}`}
284327 </ section >
285328
286329 < button data-testid = "side-channels-apply"
287- onClick = { ( ) => onApply ( draft ) } >
288- Apply
330+ disabled = { applying }
331+ onClick = { ( ) => void handleApply ( ) } >
332+ { applying ? "Applying…" : "Apply" }
289333 </ button >
334+ { applyError && (
335+ < div data-testid = "side-channels-apply-error"
336+ style = { { color : "#b91c1c" , fontSize : 11 , marginTop : 4 } } >
337+ { applyError }
338+ </ div >
339+ ) }
340+ { applyResult && (
341+ < div data-testid = "side-channels-apply-result"
342+ style = { { marginTop : 6 , fontSize : 11 ,
343+ fontFamily : "monospace" } } >
344+ < div data-testid = "side-channels-apply-summary"
345+ style = { { color : applyResult . ok ? "#047857" : "#b91c1c" } } >
346+ applied: { applyResult . active_count } active /
347+ { " " } { applyResult . inactive_count } inactive
348+ { applyResult . ok ? " · ok" : " · errors" }
349+ </ div >
350+ { applyResult . families . map ( ( f ) => (
351+ < div key = { f . family }
352+ data-testid = { `side-channels-apply-family-${ f . family } ` }
353+ style = { { color : f . active ? "#374151" : "#9ca3af" } } >
354+ · { f . family } [{ f . mode } ]: { f . active ? "active" : "inactive" } — { f . reason }
355+ </ div >
356+ ) ) }
357+ { applyResult . gotchas . map ( ( g ) => (
358+ < div key = { g . id }
359+ data-testid = { `side-channels-apply-gotcha-${ g . id } ` }
360+ style = { { color : g . severity === "error"
361+ ? "#b91c1c" : "#b45309" } } >
362+ ! { g . message }
363+ </ div >
364+ ) ) }
365+ </ div >
366+ ) }
290367 </ div >
291368 ) ;
292369
370+ async function handleApply ( ) {
371+ // V7-H11: ship the local draft through side_channels.apply BEFORE
372+ // committing to spec so the user sees backend's per-family
373+ // resolution + gotchas inline. Local commit happens regardless so
374+ // the rest of the UI (verify wiring, gotchas tab) still sees the
375+ // new config even if the backend flags warnings.
376+ setApplyError ( null ) ;
377+ setApplyResult ( null ) ;
378+ onApply ( draft ) ;
379+ if ( ! rpc ) {
380+ setApplyError ( "no backend connection — local apply only" ) ;
381+ return ;
382+ }
383+ setApplying ( true ) ;
384+ try {
385+ const r = await rpc . call < SideChannelApplyPayload > (
386+ "side_channels.apply" ,
387+ {
388+ side_channels : draft ,
389+ available_side_channels : availableChannels ,
390+ } ,
391+ ) ;
392+ setApplyResult ( r ) ;
393+ } catch ( err ) {
394+ setApplyError (
395+ err instanceof Error ? `error: ${ err . message } ` : "error: apply failed" ,
396+ ) ;
397+ } finally {
398+ setApplying ( false ) ;
399+ }
400+ }
401+
293402 function setFamily (
294403 name : string ,
295404 patch : Partial < SideChannelState [ "families" ] [ string ] > ,
@@ -317,6 +426,63 @@ families=${enabledFamilies.join(",") || "none"}`}
317426 ] ;
318427 onTrainChannelsChange ( ordered ) ;
319428 }
429+
430+ async function runPreview ( ) {
431+ setPreviewError ( null ) ;
432+ if ( ! rpc || ! tokenizerSource ) {
433+ setTensorPreview ( buildTensorPreview ( {
434+ sideChannels : draft ,
435+ prompt,
436+ platformPreview,
437+ adapter,
438+ } ) ) ;
439+ return ;
440+ }
441+ try {
442+ const result = await rpc . call < SideChannelPreviewPayload > (
443+ "side_channels.preview" ,
444+ {
445+ tokenizer_source : tokenizerSource ,
446+ text : prompt ,
447+ side_channels : draft ,
448+ platform_context : platform ,
449+ language : adapter === "none" ? undefined : adapter ,
450+ adapter,
451+ } ,
452+ ) ;
453+ setTensorPreview ( formatBackendPreview ( result ) ) ;
454+ } catch ( err ) {
455+ setTensorPreview ( [ ] ) ;
456+ setPreviewError (
457+ err instanceof Error ? `error: ${ err . message } ` : "error: preview failed" ,
458+ ) ;
459+ }
460+ }
461+ }
462+
463+ function formatBackendPreview ( result : SideChannelPreviewPayload ) : string [ ] {
464+ const lines = [
465+ `prompt_ids shape=${ shapeText ( result . prompt_ids . shape ) } dtype=${ result . prompt_ids . dtype } ` ,
466+ ] ;
467+ for ( const [ name , tensor ] of Object . entries ( result . model_kwargs ) ) {
468+ lines . push ( `${ name } shape=${ shapeText ( tensor . shape ) } dtype=${ tensor . dtype } ` ) ;
469+ }
470+ for ( const [ family , columns ] of Object . entries ( result . side_channels ) ) {
471+ for ( const [ name , tensor ] of Object . entries ( columns ) ) {
472+ lines . push (
473+ `${ name } shape=${ shapeText ( tensor . shape ) } family=${ family } dtype=${ tensor . dtype } ` ,
474+ ) ;
475+ }
476+ }
477+ for ( const [ key , value ] of Object . entries ( result . provenance ) ) {
478+ lines . push ( `${ key } =${ value } ` ) ;
479+ }
480+ lines . push ( `cache_key=${ result . cache_key . slice ( 0 , 12 ) } ` ) ;
481+ return lines ;
482+ }
483+
484+ function shapeText ( shape : number [ ] ) : string {
485+ return `(${ shape . join ( "," ) } )` ;
320486}
321487
322488function buildTensorPreview ( {
0 commit comments