@@ -357,7 +357,40 @@ def _hint_game_key_candidates(output_meta: dict[str, Any]) -> dict[str, list[str
357357 return candidates
358358
359359
360+ def _first_nonempty_text (* values : Any ) -> str :
361+ """Return the first non-empty stripped string among ``values``."""
362+ for value in values :
363+ text = str (value or "" ).strip ()
364+ if text :
365+ return text
366+ return ""
367+
368+
369+ def _schema_role_value (schema_resolution : dict [str , Any ], * names : str ) -> str :
370+ """Resolve a named schema role from ``roles`` first, then top-level keys.
371+
372+ Feature Engineering may emit ``user_table`` / ``user_id`` at the document
373+ root instead of nesting them under ``roles``. Both shapes are accepted;
374+ ``roles`` wins when both are present.
375+ """
376+ roles = schema_resolution .get ("roles" )
377+ role_map = roles if isinstance (roles , dict ) else {}
378+ from_roles = _first_nonempty_text (* (role_map .get (name ) for name in names ))
379+ if from_roles :
380+ return from_roles
381+ from_top = _first_nonempty_text (* (schema_resolution .get (name ) for name in names ))
382+ if from_top :
383+ INPUT_NORMALIZATION_WARNINGS .append (
384+ "schema_resolution resolved "
385+ + "/" .join (names )
386+ + " from top-level keys because roles did not provide them"
387+ )
388+ return from_top
389+ return ""
390+
391+
360392def load_runtime_contract () -> RuntimeContract :
393+ """Load databases, user keys, and table roles from step1 plus schema_resolution."""
361394 output_meta = _read_json ("step1_output_meta.json" )
362395 sample_stats = _read_json ("step1_sample_stats.json" )
363396 schema_resolution = _read_json ("schema_resolution.json" )
@@ -380,13 +413,14 @@ def load_runtime_contract() -> RuntimeContract:
380413 if not sampling_database :
381414 raise SystemExit ("step1_sample_stats.json must provide output_database" )
382415 target_game = str (sample_stats .get ("target_game" ) or "" ).strip ()
383- roles = schema_resolution .get ("roles" , {})
384- if not isinstance (roles , dict ):
385- raise SystemExit ("schema_resolution.roles must be an object" )
386- user_table = str (roles .get ("<user_table>" ) or roles .get ("user_table" ) or "" ).strip ()
387- user_id = str (roles .get ("<user_id>" ) or roles .get ("user_id" ) or "" ).strip ()
416+ user_table = _schema_role_value (schema_resolution , "<user_table>" , "user_table" )
417+ user_id = _schema_role_value (schema_resolution , "<user_id>" , "user_id" )
388418 if not user_table or not user_id :
389- raise SystemExit ("schema_resolution must resolve <user_table> and <user_id>" )
419+ available = ", " .join (sorted (str (key ) for key in schema_resolution .keys ()))
420+ raise SystemExit (
421+ "schema_resolution must resolve <user_table> and <user_id> "
422+ f"from roles or top-level keys; available keys: { available } "
423+ )
390424
391425 table_columns = _table_schema_map (output_meta )
392426 if user_table not in table_columns :
@@ -454,7 +488,7 @@ def load_runtime_contract() -> RuntimeContract:
454488 )
455489 )
456490
457- role_game_key = str ( roles . get ( "<game_id>" ) or roles . get ( "game_id" ) or "" ). strip ( )
491+ role_game_key = _schema_role_value ( schema_resolution , "<game_id>" , "game_id" )
458492 hint_candidates = _hint_game_key_candidates (output_meta )
459493 game_keys : dict [str , str ] = {}
460494 for table in sorted (game_dimension_tables ):
@@ -1445,6 +1479,22 @@ def build_tree_candidate(path: Path) -> CandidateSQL:
14451479 )
14461480
14471481
1482+ def _scorecard_score_expression (branches : list [str ]) -> str :
1483+ """Combine per-rule ``if(...)`` branches into one ClickHouse score expression.
1484+
1485+ Joining branches with left-associative ``+`` makes ClickHouse nest binary
1486+ ``Plus`` nodes to depth ``len(branches) - 1``. That exceeds the default
1487+ AST depth limit of 1000 once a scorecard has more than about 1000 rules.
1488+ ``arraySum`` keeps depth constant regardless of rule count.
1489+ """
1490+ if not branches :
1491+ return "CAST(0 AS Float64)"
1492+ if len (branches ) == 1 :
1493+ return branches [0 ]
1494+ inner = ",\n " .join (branches )
1495+ return f"arraySum([\n { inner } \n ])"
1496+
1497+
14481498def parse_scorecard_condition (feature : str , condition : str , alias : str = "features" ) -> str :
14491499 reference = f"{ alias } .{ _quote_identifier (feature )} "
14501500 text = str (condition ).strip ()
@@ -1467,6 +1517,7 @@ def parse_scorecard_condition(feature: str, condition: str, alias: str = "featur
14671517
14681518
14691519def build_scorecard_candidate (path : Path ) -> CandidateSQL :
1520+ """Parse a scorecard rule CSV into a deployable ClickHouse score expression."""
14701521 frame = pd .read_csv (path , encoding = "utf-8-sig" )
14711522 required = {"feature" , "condition" , "weighted_score" }
14721523 if not required .issubset (frame .columns ):
@@ -1485,7 +1536,7 @@ def build_scorecard_candidate(path: Path) -> CandidateSQL:
14851536 features .add (feature )
14861537 branches .append (f"if({ condition } , toFloat64({ score } ), toFloat64(0))" )
14871538 coverage = len (branches ) / len (frame ) if len (frame ) else 0.0
1488- expression = " \n + " . join (branches ) if branches else "CAST(0 AS Float64)"
1539+ expression = _scorecard_score_expression (branches )
14891540 return CandidateSQL (
14901541 name = "scorecard" ,
14911542 expression = expression ,
@@ -2496,11 +2547,63 @@ def _risk_flags(name: str, metrics: dict[str, Any], rule_count: int) -> list[str
24962547 return flags
24972548
24982549
2550+ _MAX_DIAGNOSTIC_ERRORS = 8
2551+ _MAX_DIAGNOSTIC_ERROR_CHARS = 300
2552+
2553+
2554+ def _truncate_diagnostic_error (text : str ) -> str :
2555+ """Keep one diagnostic line short enough for an LLM to read in a SystemExit."""
2556+ cleaned = str (text or "" ).strip ()
2557+ if len (cleaned ) <= _MAX_DIAGNOSTIC_ERROR_CHARS :
2558+ return cleaned
2559+ return cleaned [: _MAX_DIAGNOSTIC_ERROR_CHARS - 3 ] + "..."
2560+
2561+
2562+ def _format_diagnostic_errors (errors : list [str ] | None ) -> str :
2563+ """Join parse or deployment errors into a compact SystemExit fragment."""
2564+ items = [_truncate_diagnostic_error (item ) for item in (errors or [])]
2565+ items = [item for item in items if item ]
2566+ if not items :
2567+ return "(none)"
2568+ shown = items [:_MAX_DIAGNOSTIC_ERRORS ]
2569+ extra = len (items ) - len (shown )
2570+ body = "; " .join (shown )
2571+ if extra > 0 :
2572+ return f"{ body } ; ... and { extra } more"
2573+ return body
2574+
2575+
2576+ def _format_undeployable_candidate (candidate : CandidateSQL ) -> str :
2577+ """Summarize why one white-box candidate cannot be deployed."""
2578+ return (
2579+ f"{ candidate .name } : renderable={ candidate .renderable } "
2580+ f", rule_count={ candidate .rule_count } "
2581+ f", parse_coverage={ candidate .parse_coverage } "
2582+ f", parse_errors={ _format_diagnostic_errors (candidate .render_errors )} "
2583+ f", deployment_errors={ _format_diagnostic_errors (candidate .deployment_errors )} "
2584+ )
2585+
2586+
2587+ def _undeployable_whitebox_exit_message (tree : CandidateSQL , scorecard : CandidateSQL ) -> str :
2588+ """Build a SystemExit payload that tells the LLM which candidate failed and why."""
2589+ return (
2590+ "Neither white-box candidate is deployable; "
2591+ "this is an invalid technical input or lineage contract, not a model-quality gate. "
2592+ "Do not rewrite rule scores, labels, or predictions. "
2593+ "Inspect schema_resolution, rule CSV conditions, tree preprocessing, "
2594+ "and feature lineage using the diagnostics below. "
2595+ + _format_undeployable_candidate (tree )
2596+ + " ; "
2597+ + _format_undeployable_candidate (scorecard )
2598+ )
2599+
2600+
24992601def choose_strategy (
25002602 aligned : pd .DataFrame ,
25012603 tree : CandidateSQL ,
25022604 scorecard : CandidateSQL ,
25032605) -> tuple [dict [str , Any ], dict [str , float ]]:
2606+ """Compare deployable tree and scorecard candidates and pick one strategy."""
25042607 labels = aligned ["label" ].to_numpy (dtype = int )
25052608 teacher_scores = aligned ["teacher_score" ].to_numpy (dtype = float )
25062609 tree_scores = aligned ["tree_score" ].to_numpy (dtype = float )
@@ -2522,10 +2625,7 @@ def choose_strategy(
25222625 card_metrics ["risk_flags" ] = _risk_flags ("scorecard" , card_metrics , scorecard .rule_count )
25232626
25242627 if not tree .renderable and not scorecard .renderable :
2525- raise SystemExit (
2526- "Neither white-box rule artifact is fully parseable; "
2527- "this is an invalid technical input contract, not a model-quality gate"
2528- )
2628+ raise SystemExit (_undeployable_whitebox_exit_message (tree , scorecard ))
25292629 if tree .renderable != scorecard .renderable :
25302630 strategy = "decision_tree" if tree .renderable else "scorecard"
25312631 return (
@@ -3191,6 +3291,8 @@ def render_features(
31913291 raise SystemExit (
31923292 "Cannot render a deployable white-box SQL after lineage normalization: "
31933293 + str (exc )
3294+ + ". "
3295+ + _undeployable_whitebox_exit_message (tree , scorecard )
31943296 ) from exc
31953297 fallback = alternatives [0 ]
31963298 strategy = fallback .name
0 commit comments