mikeboone commited on
Commit
d02d82c
Β·
2 Parent(s): 0b6d8494f0378f

Merge remote-tracking branch 'hf-mcp-test/main' into fix/blueprint-llm-timeout

Browse files
chat_interface.py CHANGED
@@ -55,7 +55,7 @@ def _collect_run_findings(controller, results: dict | None = None) -> list:
55
  Module-level and getattr-based on purpose: the completion record is built
56
  for bare controller stubs in tests as well as the real interface.
57
  """
58
- from demoprep_app.quality.findings import Finding
59
 
60
  found = list(
61
  getattr(controller, "_run_findings", None)
@@ -67,7 +67,9 @@ def _collect_run_findings(controller, results: dict | None = None) -> list:
67
  found.append(raw if isinstance(raw, Finding) else Finding.from_dict(raw))
68
  except Exception:
69
  continue
70
- return found
 
 
71
  from supabase_client import load_gradio_settings, get_admin_setting, inject_admin_settings_to_env
72
  from main_research import MultiLLMResearcher, Website
73
  from demo_personas import (
@@ -3967,6 +3969,7 @@ LegitData will generate realistic, AI-powered data.
3967
  from demoprep_app.integrations.snowflake import (
3968
  MeasureQualityGateError,
3969
  apply_measure_omissions,
 
3970
  measure_formulas,
3971
  measure_shares,
3972
  run_measure_quality_gate,
@@ -3977,6 +3980,10 @@ LegitData will generate realistic, AI-powered data.
3977
  log_progress("Running data-quality gate on measure columns...")
3978
  _dq_formulas = measure_formulas(self._demo_bundle) if self._demo_bundle else {}
3979
  _dq_shares = measure_shares(self._demo_bundle) if self._demo_bundle else {}
 
 
 
 
3980
  _dq_required = sorted({
3981
  i.measure for i in getattr(self._demo_blueprint, "insights", []) or [] if i.measure
3982
  })
@@ -3992,6 +3999,7 @@ LegitData will generate realistic, AI-powered data.
3992
  progress_callback=log_progress,
3993
  formulas=_dq_formulas,
3994
  shares=_dq_shares,
 
3995
  required_measures=_dq_required,
3996
  fact_tables=_dq_facts,
3997
  )
@@ -4011,9 +4019,12 @@ LegitData will generate realistic, AI-powered data.
4011
  _slog.log("deploy", "data quality gate failed", error=str(e))
4012
  try:
4013
  from demoprep_app.quality.findings import Finding as _Finding
4014
- _gate_findings = list(getattr(self, "_build_findings", []) or []) + [
4015
- _Finding.from_dict(d) for d in e.profile.get("findings", [])
4016
- ]
 
 
 
4017
  except Exception:
4018
  _gate_findings = []
4019
  self._surface_findings(
@@ -4065,9 +4076,14 @@ LegitData will generate realistic, AI-powered data.
4065
  from pathlib import Path as _Path
4066
 
4067
  from demoprep_app.integrations.snowflake.quality_gate import gate_findings
 
4068
  from demoprep_app.quality.findings import write_findings
4069
 
4070
- _all_findings = list(getattr(self, "_build_findings", []) or []) + gate_findings(dq_profile)
 
 
 
 
4071
  self._run_findings = list(_all_findings)
4072
  if _all_findings:
4073
  _fpath = write_findings(
@@ -4285,7 +4301,7 @@ Tables: Created and populated
4285
  # Auth a deployer so we can pass ts_client to MCP
4286
  ts_client = ThoughtSpotDeployer(ts_url, ts_user, ts_secret)
4287
  if not ts_client.authenticate():
4288
- raise ValueError("ThoughtSpot authentication failed.")
4289
 
4290
  llm_model = self.settings.get('model', DEFAULT_LLM_MODEL)
4291
 
 
55
  Module-level and getattr-based on purpose: the completion record is built
56
  for bare controller stubs in tests as well as the real interface.
57
  """
58
+ from demoprep_app.quality.findings import Finding, dedupe
59
 
60
  found = list(
61
  getattr(controller, "_run_findings", None)
 
67
  found.append(raw if isinstance(raw, Finding) else Finding.from_dict(raw))
68
  except Exception:
69
  continue
70
+ # One record per (code, table, measure) whichever stage noticed it; the
71
+ # post-load record wins (quality.findings.dedupe).
72
+ return dedupe(found)
73
  from supabase_client import load_gradio_settings, get_admin_setting, inject_admin_settings_to_env
74
  from main_research import MultiLLMResearcher, Website
75
  from demo_personas import (
 
3969
  from demoprep_app.integrations.snowflake import (
3970
  MeasureQualityGateError,
3971
  apply_measure_omissions,
3972
+ measure_formats,
3973
  measure_formulas,
3974
  measure_shares,
3975
  run_measure_quality_gate,
 
3980
  log_progress("Running data-quality gate on measure columns...")
3981
  _dq_formulas = measure_formulas(self._demo_bundle) if self._demo_bundle else {}
3982
  _dq_shares = measure_shares(self._demo_bundle) if self._demo_bundle else {}
3983
+ # The blueprint's declared fmt / signed per measure, so the gate
3984
+ # infers the same bound the blueprint did (a count or an amount is
3985
+ # non-negative, not "unclassified").
3986
+ _dq_formats = measure_formats(self._demo_bundle) if self._demo_bundle else {}
3987
  _dq_required = sorted({
3988
  i.measure for i in getattr(self._demo_blueprint, "insights", []) or [] if i.measure
3989
  })
 
3999
  progress_callback=log_progress,
4000
  formulas=_dq_formulas,
4001
  shares=_dq_shares,
4002
+ formats=_dq_formats,
4003
  required_measures=_dq_required,
4004
  fact_tables=_dq_facts,
4005
  )
 
4019
  _slog.log("deploy", "data quality gate failed", error=str(e))
4020
  try:
4021
  from demoprep_app.quality.findings import Finding as _Finding
4022
+ from demoprep_app.quality.findings import dedupe as _dedupe_findings
4023
+ _gate_findings = _dedupe_findings(
4024
+ list(getattr(self, "_build_findings", []) or []) + [
4025
+ _Finding.from_dict(d) for d in e.profile.get("findings", [])
4026
+ ]
4027
+ )
4028
  except Exception:
4029
  _gate_findings = []
4030
  self._surface_findings(
 
4076
  from pathlib import Path as _Path
4077
 
4078
  from demoprep_app.integrations.snowflake.quality_gate import gate_findings
4079
+ from demoprep_app.quality.findings import dedupe as _dedupe_findings
4080
  from demoprep_app.quality.findings import write_findings
4081
 
4082
+ # One record per (code, table, measure) across the two gates;
4083
+ # the post-load record wins β€” it carries the observed stats.
4084
+ _all_findings = _dedupe_findings(
4085
+ list(getattr(self, "_build_findings", []) or []) + gate_findings(dq_profile)
4086
+ )
4087
  self._run_findings = list(_all_findings)
4088
  if _all_findings:
4089
  _fpath = write_findings(
 
4301
  # Auth a deployer so we can pass ts_client to MCP
4302
  ts_client = ThoughtSpotDeployer(ts_url, ts_user, ts_secret)
4303
  if not ts_client.authenticate():
4304
+ raise ValueError(ts_client.auth_failure_message())
4305
 
4306
  llm_model = self.settings.get('model', DEFAULT_LLM_MODEL)
4307
 
demo_builder_class.py CHANGED
@@ -196,7 +196,9 @@ class DemoBuilder:
196
  for remove_word in ["Home", "Welcome", "Official Site", "Website"]:
197
  name = name.replace(remove_word, "").strip()
198
  if name: # Only return if we got something useful
199
- return name[:30] # Limit length
 
 
200
 
201
  # Fallback to domain name (user's original input like "Amazon.com" -> "Amazon")
202
  domain = self.company_url.replace("https://", "").replace("http://", "").replace("www.", "").split("/")[0]
 
196
  for remove_word in ["Home", "Welcome", "Official Site", "Website"]:
197
  name = name.replace(remove_word, "").strip()
198
  if name: # Only return if we got something useful
199
+ # 30 cut "OMERS Administration Corporation" to "…Corporati"
200
+ # on every record of the run (findings triage 2026-10-15, F).
201
+ return name[:80].strip()
202
 
203
  # Fallback to domain name (user's original input like "Amazon.com" -> "Amazon")
204
  domain = self.company_url.replace("https://", "").replace("http://", "").replace("www.", "").split("/")[0]
demoprep_app/dataset/contracts.py CHANGED
@@ -18,6 +18,16 @@ class DatasetColumn:
18
  #: The measure this one is generated as a fraction of (blueprint share_of),
19
  #: so PART <= WHOLE by construction. Read by the post-load part/whole check.
20
  share_of: str | None = None
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
  @dataclass(slots=True)
 
18
  #: The measure this one is generated as a fraction of (blueprint share_of),
19
  #: so PART <= WHOLE by construction. Read by the post-load part/whole check.
20
  share_of: str | None = None
21
+ #: The blueprint's declared format ("int" | "currency" | "float" | "pct"),
22
+ #: whether the measure may legitimately be negative, and "base" | "derived".
23
+ #: Empty / False for non-measure columns. The post-load quality gate reads
24
+ #: these (quality_gate.measure_formats) so it infers the same bound the
25
+ #: blueprint and the engine did: without the format every integer count and
26
+ #: every money column came back unclassified and was posted as a
27
+ #: vocabulary gap (~600 of 662 Slack warnings, 2026-10-12..15).
28
+ fmt: str = ""
29
+ signed: bool = False
30
+ measure_kind: str = ""
31
 
32
 
33
  @dataclass(slots=True)
demoprep_app/dataset/engine.py CHANGED
@@ -77,6 +77,11 @@ class BlueprintEngine:
77
  # finding can state the expected range and the
78
  # suggested fix rather than just a row count
79
  self._bound_specs: dict[str, MeasureBound] = {}
 
 
 
 
 
80
 
81
  def formula_warnings(self) -> list[str]:
82
  """One deduplicated line per distinct formula warning, with row counts."""
@@ -151,10 +156,7 @@ class BlueprintEngine:
151
  "That flattens the measure on a chart β€” its formula or its inputs need "
152
  "fixing rather than bounding"
153
  ),
154
- suggestion=(
155
- f"lower the declared level (params.mean) for {name} or fix the formula's "
156
- "inputs so the values live inside the bound instead of against it"
157
- ),
158
  measure=name,
159
  row_share=round(share, 4),
160
  extra={"rows_on_bound": hits, "rows": rows},
@@ -165,6 +167,17 @@ class BlueprintEngine:
165
  """The printed rendering of bound_findings(). The records are the data."""
166
  return [f.render_line() for f in self.bound_findings()]
167
 
 
 
 
 
 
 
 
 
 
 
 
168
  def generate(self, blueprint: DemoBlueprint, row_count: int | None = None) -> DatasetBundle:
169
  # Validate formulas before generating a single row: a bad formula is a
170
  # blueprint defect, and failing here names every offender at once
@@ -181,9 +194,9 @@ class BlueprintEngine:
181
  self._bound_cap_hits = {}
182
  self._bound_rows = {}
183
  self._bound_specs = {}
 
184
  rng = random.Random(blueprint.seed or 1)
185
- total_target = max(500, int(row_count or 5000))
186
- per_fact = max(500, total_target // max(1, len(blueprint.facts)))
187
 
188
  dates = _date_spine(blueprint)
189
  dim_tables = {d.name: self._dimension_table(d) for d in blueprint.dimensions}
@@ -274,6 +287,11 @@ class BlueprintEngine:
274
  columns.append(DatasetColumn(
275
  m.name, m.name.lower(), _sql_type(m), nullable=False,
276
  formula=m.formula or None, share_of=m.share_of or None,
 
 
 
 
 
277
  ))
278
 
279
  pickers = []
@@ -301,8 +319,9 @@ class BlueprintEngine:
301
  # Entries are (insight, effective_multiplier).
302
  insights_by_measure: dict[str, list[tuple[PlantedInsight, float]]] = {}
303
  for insight in blueprint.insights:
 
304
  if insight.measure in fact_measure_names:
305
- insights_by_measure.setdefault(insight.measure, []).append((insight, insight.magnitude))
306
  continue
307
  m = measures_by_name.get(insight.measure)
308
  if m is None or not m.formula:
@@ -310,7 +329,7 @@ class BlueprintEngine:
310
  base, sign = _pick_sensitivity_base(m.formula, measures_by_name, fact_measure_names)
311
  if base is None:
312
  continue
313
- effmult = insight.magnitude if sign > 0 else (1.0 / insight.magnitude if insight.magnitude else 1.0)
314
  insights_by_measure.setdefault(base, []).append((insight, effmult))
315
 
316
  rows: list[dict[str, Any]] = []
@@ -480,9 +499,12 @@ class BlueprintEngine:
480
  bound: MeasureBound | None = getattr(measure, "bound", None)
481
  name = measure.name
482
  self._bound_rows[name] = self._bound_rows.get(name, 0) + 1
483
- if bound is None or bound.gap:
 
 
484
  return value
485
  self._bound_specs[name] = bound
 
486
 
487
  numeric = float(value)
488
  if bound.violates(numeric):
@@ -626,9 +648,33 @@ def _sample_in_band(measure: BlueprintMeasure, rng: random.Random, drive: float)
626
  return _format_bounded(measure, low + (high - low) * _position_in_band(rng, drive))
627
 
628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  def _date_spine(blueprint: DemoBlueprint) -> list[date]:
630
- end = date.today().replace(day=1) - timedelta(days=1)
631
- start = end - timedelta(days=int(blueprint.date.months_of_history * 30.44))
 
632
  dates: list[date] = []
633
  if blueprint.date.grain == "month":
634
  current = start.replace(day=1)
@@ -671,6 +717,17 @@ def _sample(measure: BlueprintMeasure, rng: random.Random) -> float:
671
  return rng.lognormvariate(mu, sigma)
672
 
673
 
 
 
 
 
 
 
 
 
 
 
 
674
  def _insight_multiplier(entries, picked: dict[str, str], d: date) -> float:
675
  """entries: iterable of (insight, effective_multiplier). The effective
676
  multiplier may differ from insight.magnitude when the insight targets a
 
77
  # finding can state the expected range and the
78
  # suggested fix rather than just a row count
79
  self._bound_specs: dict[str, MeasureBound] = {}
80
+ # _bound_measures[name] the measure itself, so a pile-up's suggested
81
+ # fix can match its CAUSE (a derived ratio
82
+ # whose numerator is sampled independently of
83
+ # its denominator vs a base level set too high)
84
+ self._bound_measures: dict[str, BlueprintMeasure] = {}
85
 
86
  def formula_warnings(self) -> list[str]:
87
  """One deduplicated line per distinct formula warning, with row counts."""
 
156
  "That flattens the measure on a chart β€” its formula or its inputs need "
157
  "fixing rather than bounding"
158
  ),
159
+ suggestion=_pileup_suggestion(name, self._bound_measures.get(name)),
 
 
 
160
  measure=name,
161
  row_share=round(share, 4),
162
  extra={"rows_on_bound": hits, "rows": rows},
 
167
  """The printed rendering of bound_findings(). The records are the data."""
168
  return [f.render_line() for f in self.bound_findings()]
169
 
170
+ @staticmethod
171
+ def rows_per_fact(blueprint: DemoBlueprint, row_count: int | None = None) -> int:
172
+ """How many rows generate() writes to EACH fact for a row-count guidance.
173
+
174
+ Exposed so the pipeline can size a planted insight's segment BEFORE
175
+ generation (DemoBlueprint.ensure_insight_support) with the number the
176
+ engine will actually use.
177
+ """
178
+ total_target = max(500, int(row_count or 5000))
179
+ return max(500, total_target // max(1, len(blueprint.facts)))
180
+
181
  def generate(self, blueprint: DemoBlueprint, row_count: int | None = None) -> DatasetBundle:
182
  # Validate formulas before generating a single row: a bad formula is a
183
  # blueprint defect, and failing here names every offender at once
 
194
  self._bound_cap_hits = {}
195
  self._bound_rows = {}
196
  self._bound_specs = {}
197
+ self._bound_measures = {}
198
  rng = random.Random(blueprint.seed or 1)
199
+ per_fact = self.rows_per_fact(blueprint, row_count)
 
200
 
201
  dates = _date_spine(blueprint)
202
  dim_tables = {d.name: self._dimension_table(d) for d in blueprint.dimensions}
 
287
  columns.append(DatasetColumn(
288
  m.name, m.name.lower(), _sql_type(m), nullable=False,
289
  formula=m.formula or None, share_of=m.share_of or None,
290
+ # The measure's declared semantics travel with the column, so
291
+ # the post-load gate infers the SAME bound the blueprint did
292
+ # (quality_gate.measure_formats).
293
+ fmt=m.fmt or "", signed=bool(getattr(m, "signed", False)),
294
+ measure_kind=m.kind or "",
295
  ))
296
 
297
  pickers = []
 
319
  # Entries are (insight, effective_multiplier).
320
  insights_by_measure: dict[str, list[tuple[PlantedInsight, float]]] = {}
321
  for insight in blueprint.insights:
322
+ drive = _insight_drive(insight)
323
  if insight.measure in fact_measure_names:
324
+ insights_by_measure.setdefault(insight.measure, []).append((insight, drive))
325
  continue
326
  m = measures_by_name.get(insight.measure)
327
  if m is None or not m.formula:
 
329
  base, sign = _pick_sensitivity_base(m.formula, measures_by_name, fact_measure_names)
330
  if base is None:
331
  continue
332
+ effmult = drive if sign > 0 else (1.0 / drive if drive else 1.0)
333
  insights_by_measure.setdefault(base, []).append((insight, effmult))
334
 
335
  rows: list[dict[str, Any]] = []
 
499
  bound: MeasureBound | None = getattr(measure, "bound", None)
500
  name = measure.name
501
  self._bound_rows[name] = self._bound_rows.get(name, 0) + 1
502
+ # No class matched, the range could not be inferred, or the measure is
503
+ # signed and has no range by design: nothing to enforce or count.
504
+ if bound is None or bound.gap or bound.unbounded:
505
  return value
506
  self._bound_specs[name] = bound
507
+ self._bound_measures[name] = measure
508
 
509
  numeric = float(value)
510
  if bound.violates(numeric):
 
648
  return _format_bounded(measure, low + (high - low) * _position_in_band(rng, drive))
649
 
650
 
651
+ def _pileup_suggestion(name: str, measure: BlueprintMeasure | None) -> str:
652
+ """The fix for rows piled on a bound, by CAUSE. The old text said "lower
653
+ params.mean" for every pile-up β€” a derived ratio has no params.mean
654
+ (FILL_RATE, GROSS_MARGIN_RATE, LAKE_CAPACITY_UTILIZATION_RATE; findings
655
+ triage 2026-10-15, C2)."""
656
+ if measure is not None and measure.kind == "derived" and measure.formula:
657
+ return (
658
+ f"{name} = '{measure.formula}' is clamped at its cap on these rows because its "
659
+ "numerator is sampled independently of its denominator: declare the numerator "
660
+ "share_of the denominator in the blueprint (params.low/high as the fraction range) "
661
+ "so the ratio is inside the bound by construction"
662
+ )
663
+ if measure is not None and measure.share_of:
664
+ return (
665
+ f"{name} is a share of {measure.share_of} whose params.high reaches the cap β€” lower "
666
+ "params.high (and params.low) so the fraction stays inside the bound"
667
+ )
668
+ return (
669
+ f"lower the declared level (params.mean) for {name} or fix the formula's "
670
+ "inputs so the values live inside the bound instead of against it"
671
+ )
672
+
673
+
674
  def _date_spine(blueprint: DemoBlueprint) -> list[date]:
675
+ # The same span DateSpec.span() reports, so blueprint-time window checks and
676
+ # the generated dates agree to the day.
677
+ start, end = blueprint.date.span()
678
  dates: list[date] = []
679
  if blueprint.date.grain == "month":
680
  current = start.replace(day=1)
 
717
  return rng.lognormvariate(mu, sigma)
718
 
719
 
720
+ def _insight_drive(insight: PlantedInsight) -> float:
721
+ """The multiplier the engine applies for an insight: its magnitude, pushed
722
+ further by `drive_boost` in the insight's own direction (a laggard is pushed
723
+ DOWN). drive_boost is 1.0 unless the tuning loop raised it for a bounded
724
+ measure, where more drive moves the row's position in its band and a
725
+ bigger magnitude would only have moved the pass bar (B2)."""
726
+ boost = max(float(getattr(insight, "drive_boost", 1.0) or 1.0), 1e-6)
727
+ magnitude = float(insight.magnitude or 1.0)
728
+ return magnitude * boost if magnitude >= 1.0 else magnitude / boost
729
+
730
+
731
  def _insight_multiplier(entries, picked: dict[str, str], d: date) -> float:
732
  """entries: iterable of (insight, effective_multiplier). The effective
733
  multiplier may differ from insight.magnitude when the insight targets a
demoprep_app/dataset/formula.py CHANGED
@@ -240,6 +240,23 @@ def validate(formula: str, allowed_identifiers: Iterable[str], *, measure: str =
240
  allowed = {str(name) for name in allowed_identifiers}
241
  prefix = f"Measure {measure}: " if measure else ""
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  for node in ast.walk(tree):
244
  if isinstance(node, ast.Call):
245
  func = node.func.id.upper() if isinstance(node.func, ast.Name) else ""
@@ -540,17 +557,66 @@ def alias_target(formula: str) -> str:
540
  demo whose optimisation produces no change has no story at all.
541
 
542
  A redundant clamp around a copy is still a copy, so MIN/MAX/ROUND/COALESCE
543
- wrapping a single identifier unwraps to that identifier.
 
 
 
544
  """
545
  try:
546
  node = parse(formula)
547
  except FormulaEvaluationError:
548
  return ""
549
- while (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
550
- and node.func.id.upper() in _SIGN_PRESERVING_FUNCTIONS and node.args):
551
- non_constant = [a for a in node.args if not isinstance(a, ast.Constant)
552
- and not (isinstance(a, ast.UnaryOp) and isinstance(a.operand, ast.Constant))]
553
- if len(non_constant) != 1:
554
- return ""
555
- node = non_constant[0]
 
 
 
 
 
 
556
  return node.id if isinstance(node, ast.Name) else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  allowed = {str(name) for name in allowed_identifiers}
241
  prefix = f"Measure {measure}: " if measure else ""
242
 
243
+ # A formula that is a COPY of another measure β€” bare (`X`) or dressed as an
244
+ # arithmetic identity (`X * 1`, `1 * X`, `X / 1`, `X + 0`) β€” parses,
245
+ # evaluates and ships a column identical to its source on every row. It is
246
+ # reported here, before generation, so the repair loop fixes or demotes it:
247
+ # Humpty Dump's DAYS_ON_SITE = RENTAL_DAYS * 1 (2026-10-13) sailed through
248
+ # validation, was caught post-load as a mirror, "repaired" by recomputing
249
+ # the same identity, and dropped as repair_failed β€” three builds in 70
250
+ # minutes for one measure.
251
+ copied = alias_target(formula)
252
+ if copied:
253
+ shape = "a bare alias" if copied == (formula or "").strip() else "an arithmetic identity"
254
+ problems.append(
255
+ f"{prefix}formula '{formula}' is {shape} of {copied} β€” it would store a column "
256
+ f"identical to {copied} on every row. Compute something different from {copied}, "
257
+ "or drop this measure."
258
+ )
259
+
260
  for node in ast.walk(tree):
261
  if isinstance(node, ast.Call):
262
  func = node.func.id.upper() if isinstance(node.func, ast.Name) else ""
 
557
  demo whose optimisation produces no change has no story at all.
558
 
559
  A redundant clamp around a copy is still a copy, so MIN/MAX/ROUND/COALESCE
560
+ wrapping a single identifier unwraps to that identifier. So is an
561
+ arithmetic identity: `X * 1`, `1 * X`, `X / 1`, `X + 0`, `0 + X`, `X - 0`
562
+ (and nestings of those) all evaluate to X on every row β€” see
563
+ _identity_operand.
564
  """
565
  try:
566
  node = parse(formula)
567
  except FormulaEvaluationError:
568
  return ""
569
+ while True:
570
+ if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
571
+ and node.func.id.upper() in _SIGN_PRESERVING_FUNCTIONS and node.args):
572
+ non_constant = [a for a in node.args if not isinstance(a, ast.Constant)
573
+ and not (isinstance(a, ast.UnaryOp) and isinstance(a.operand, ast.Constant))]
574
+ if len(non_constant) != 1:
575
+ return ""
576
+ node = non_constant[0]
577
+ continue
578
+ inner = _identity_operand(node)
579
+ if inner is None:
580
+ break
581
+ node = inner
582
  return node.id if isinstance(node, ast.Name) else ""
583
+
584
+
585
+ def _literal(node: ast.AST) -> float | None:
586
+ """The value of a bare numeric literal node, else None."""
587
+ if (isinstance(node, ast.Constant) and isinstance(node.value, (int, float))
588
+ and not isinstance(node.value, bool)):
589
+ return float(node.value)
590
+ return None
591
+
592
+
593
+ def _identity_operand(node: ast.AST) -> ast.AST | None:
594
+ """The operand an arithmetic identity leaves unchanged, else None.
595
+
596
+ `X * 1`, `1 * X`, `X / 1`, `X + 0`, `0 + X` and `X - 0` evaluate to X on
597
+ every row. Humpty Dump's DAYS_ON_SITE = RENTAL_DAYS * 1 (2026-10-13) passed
598
+ validation because RENTAL_DAYS existed, stored a copy, and cost three
599
+ builds. Only a bare literal counts β€” `X * (2 - 1)` is left to the post-load
600
+ mirror check β€” and a literal in any other role (`X * 2`, `X / 100`) is a
601
+ real computation.
602
+ """
603
+ if not isinstance(node, ast.BinOp):
604
+ return None
605
+ left, right = _literal(node.left), _literal(node.right)
606
+ if isinstance(node.op, ast.Mult):
607
+ if right == 1.0:
608
+ return node.left
609
+ if left == 1.0:
610
+ return node.right
611
+ elif isinstance(node.op, ast.Div):
612
+ if right == 1.0:
613
+ return node.left
614
+ elif isinstance(node.op, ast.Add):
615
+ if right == 0.0:
616
+ return node.left
617
+ if left == 0.0:
618
+ return node.right
619
+ elif isinstance(node.op, ast.Sub):
620
+ if right == 0.0:
621
+ return node.left
622
+ return None
demoprep_app/dataset/measure_bounds.py CHANGED
@@ -72,6 +72,8 @@ from demoprep_app.dataset.formula import ratio_pairs
72
  from demoprep_app.dataset.measure_names import (
73
  CURRENCY_TOKENS,
74
  is_flag_column,
 
 
75
  is_proportion_column,
76
  is_quantity_unit_name,
77
  is_score_column,
@@ -88,6 +90,21 @@ from demoprep_app.dataset.measure_names import (
88
  SCORE_SCALES = (1.0, 5.0, 10.0, 100.0, 850.0, 1000.0)
89
  SCORE_SCALE_HEADROOM = 1.05
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  # --- physical / market unit registry ---------------------------------------
92
  # Keyed on a UNIT token in the name, not on the column name, so any future
93
  # column carrying the same unit inherits the same bound. Extend this registry
@@ -132,7 +149,14 @@ class MeasureBound:
132
  """The legal range of a measure, plus how we know.
133
 
134
  low/high are inclusive; None means unbounded on that side.
135
- kind: "rate" | "score" | "non_negative" | "physical" | "market"
 
 
 
 
 
 
 
136
  scale: for a rate, 1.0 or 100.0 β€” the convention the column uses.
137
  hard: True when a value outside the range is semantically impossible
138
  (a 177% activation rate, a 53 Hz grid). Hard bounds are enforced;
@@ -161,6 +185,12 @@ class MeasureBound:
161
  def bounded_both_ends(self) -> bool:
162
  return self.low is not None and self.high is not None and self.gap == ""
163
 
 
 
 
 
 
 
164
  def violates(self, value: float) -> bool:
165
  if self.gap:
166
  return False
@@ -209,6 +239,11 @@ class MeasureBound:
209
  )
210
  if self.kind == "flag":
211
  return f"{name} is a *_FLAG column: generate 0/1 only, or drop the _FLAG suffix"
 
 
 
 
 
212
  return SUGGEST_ADD_VOCABULARY.format(name=name)
213
 
214
 
@@ -390,6 +425,30 @@ def _is_count_wearing_a_rate_name(fmt: str, scale: float, params: dict | None) -
390
  return level is not None and level > scale * SCORE_SCALE_HEADROOM
391
 
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  def _score_bound(name: str, params: dict | None) -> MeasureBound:
394
  numeric = _numeric_params(params)
395
  toks = tokens(name)
@@ -402,6 +461,11 @@ def _score_bound(name: str, params: dict | None) -> MeasureBound:
402
  if low is not None and high is not None and high > low:
403
  return MeasureBound(low, high, "score", "range declared in params.low/high", scale=high)
404
 
 
 
 
 
 
405
  mean = numeric.get("mean")
406
  if mean is None:
407
  return MeasureBound(
@@ -423,6 +487,20 @@ def _score_bound(name: str, params: dict | None) -> MeasureBound:
423
  )
424
 
425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  def infer_bound(
427
  name: str,
428
  fmt: str = "float",
@@ -431,7 +509,12 @@ def infer_bound(
431
  signed: bool = False,
432
  formula: str = "",
433
  ) -> MeasureBound | None:
434
- """The legal range for a measure, or None when nothing about it is bounded.
 
 
 
 
 
435
 
436
  Precedence: a physical/market unit beats a name class (a SPOT_PRICE_EUR_MWH
437
  is capped by the market, not merely non-negative), then proportion, then
@@ -463,6 +546,16 @@ def infer_bound(
463
  # (UNIT_MARKET_SHARE = POS_UNITS_SOLD / CATEGORY_TOTAL_UNITS).
464
  units_cancel = _ratio_units_cancel(formula)
465
  is_quantity = is_quantity_unit_name(name) and not units_cancel
 
 
 
 
 
 
 
 
 
 
466
  if (
467
  (is_proportion_column(name, units_cancel=units_cancel) and not is_quantity)
468
  or (fmt == "pct" and not is_quantity)
@@ -482,7 +575,14 @@ def infer_bound(
482
  return _score_bound(name, params)
483
 
484
  if name_signed:
485
- return None # a variance/delta/error has no semantic bound
 
 
 
 
 
 
 
486
 
487
  # An explicit token wins over the formula's shape. REVENUE_LEAKAGE_USD was
488
  # derived as a difference and went negative in 37% of rows; "leakage" is a
@@ -494,18 +594,24 @@ def infer_bound(
494
  0.0, None, "non_negative", f"'{named[0].lower()}' cannot be negative"
495
  )
496
 
497
- # Weak, format-derived non-negativity: a count or an amount of money. This
498
- # one DOES yield to a formula that is a genuine difference (a profit, a
499
- # net change), because the format alone says nothing about meaning.
500
- if fmt in ("int", "currency"):
 
 
 
 
501
  if formula:
502
  from demoprep_app.dataset.formula import is_signed as _formula_is_signed
503
 
504
  if _formula_is_signed(formula):
505
- return None
506
- return MeasureBound(
507
- 0.0, None, "non_negative", f"a {fmt} measure cannot be negative", hard=(fmt == "int")
508
- )
 
 
509
 
510
  return None
511
 
 
72
  from demoprep_app.dataset.measure_names import (
73
  CURRENCY_TOKENS,
74
  is_flag_column,
75
+ is_net_name,
76
+ is_open_ratio_name,
77
  is_proportion_column,
78
  is_quantity_unit_name,
79
  is_score_column,
 
90
  SCORE_SCALES = (1.0, 5.0, 10.0, 100.0, 850.0, 1000.0)
91
  SCORE_SCALE_HEADROOM = 1.05
92
 
93
+ #: Scores whose scale is fixed by what they ARE, keyed on the token in the
94
+ #: name: (low, high). Consulted after a declared params.low/high (the author's
95
+ #: explicit statement) and before the mean ladder. MEDIAN_SAT_SCORE (mean
96
+ #: 1530) fell off the ladder and AVG_GPA landed on 0-5 (findings triage
97
+ #: 2026-10-15, A4). The level disambiguates: SAT under 200 is a satisfaction
98
+ #: score, CSAT above 5 is on a 0-100 scale, GPA above 4 is weighted (0-5).
99
+ NAMED_SCORE_SCALES = {
100
+ "NPS": (-100.0, 100.0),
101
+ "CSAT": (1.0, 5.0),
102
+ "CES": (1.0, 7.0),
103
+ "GPA": (0.0, 4.0),
104
+ "SAT": (400.0, 1600.0),
105
+ "ACT": (1.0, 36.0),
106
+ }
107
+
108
  # --- physical / market unit registry ---------------------------------------
109
  # Keyed on a UNIT token in the name, not on the column name, so any future
110
  # column carrying the same unit inherits the same bound. Extend this registry
 
149
  """The legal range of a measure, plus how we know.
150
 
151
  low/high are inclusive; None means unbounded on that side.
152
+ kind: "rate" | "score" | "non_negative" | "physical" | "market" | "flag"
153
+ | "signed" β€” the last is "unbounded BY DESIGN": a variance, a net
154
+ change, a margin, an `A - B` difference. Both ends open, no gap,
155
+ `hard=False`, so nothing is clamped or reported. It exists so a
156
+ caller can tell "this measure has no legal range" from "no class
157
+ matched this name" (infer_bound returned None): the post-load gate
158
+ could not, and posted every NET_/MARGIN/VARIANCE column as a
159
+ vocabulary gap.
160
  scale: for a rate, 1.0 or 100.0 β€” the convention the column uses.
161
  hard: True when a value outside the range is semantically impossible
162
  (a 177% activation rate, a 53 Hz grid). Hard bounds are enforced;
 
185
  def bounded_both_ends(self) -> bool:
186
  return self.low is not None and self.high is not None and self.gap == ""
187
 
188
+ @property
189
+ def unbounded(self) -> bool:
190
+ """True for a bound that constrains nothing (kind "signed"): no value
191
+ can violate it and nothing can pile up on it."""
192
+ return self.low is None and self.high is None and self.gap == ""
193
+
194
  def violates(self, value: float) -> bool:
195
  if self.gap:
196
  return False
 
239
  )
240
  if self.kind == "flag":
241
  return f"{name} is a *_FLAG column: generate 0/1 only, or drop the _FLAG suffix"
242
+ if self.kind == "signed":
243
+ return (
244
+ f"{name} is a signed quantity ({self.source}) with no semantic range, so there "
245
+ "is nothing to bound β€” rename it if it is really a one-sided amount"
246
+ )
247
  return SUGGEST_ADD_VOCABULARY.format(name=name)
248
 
249
 
 
425
  return level is not None and level > scale * SCORE_SCALE_HEADROOM
426
 
427
 
428
+ def named_score_scale(name: str, params: dict | None) -> tuple[float, float, str] | None:
429
+ """(low, high, why) when the NAME fixes the score's scale (NAMED_SCORE_SCALES),
430
+ else None. The declared level rules out the wrong reading of an ambiguous
431
+ token (SAT the exam vs SAT a satisfaction score)."""
432
+ toks = tokens(name)
433
+ numeric = _numeric_params(params)
434
+ level = numeric.get("mean")
435
+ if level is None:
436
+ level = numeric.get("high")
437
+ for token, (low, high) in NAMED_SCORE_SCALES.items():
438
+ if token not in toks:
439
+ continue
440
+ if token == "GPA" and level is not None and level > 4.0:
441
+ return 0.0, 5.0, "a weighted GPA is 0-5"
442
+ if token == "CSAT" and level is not None and level > high:
443
+ continue # a 0-100 CSAT: the ladder reads it
444
+ if token == "SAT" and level is not None and level < 200:
445
+ continue # 'SAT' as satisfaction, not the exam
446
+ if token == "ACT" and level is not None and level > high:
447
+ continue
448
+ return low, high, f"{token} is {low:g}-{high:g} by definition"
449
+ return None
450
+
451
+
452
  def _score_bound(name: str, params: dict | None) -> MeasureBound:
453
  numeric = _numeric_params(params)
454
  toks = tokens(name)
 
461
  if low is not None and high is not None and high > low:
462
  return MeasureBound(low, high, "score", "range declared in params.low/high", scale=high)
463
 
464
+ named = named_score_scale(name, params)
465
+ if named is not None:
466
+ low, high, why = named
467
+ return MeasureBound(low, high, "score", why, scale=high)
468
+
469
  mean = numeric.get("mean")
470
  if mean is None:
471
  return MeasureBound(
 
487
  )
488
 
489
 
490
+ def _signed_bound(why: str) -> MeasureBound:
491
+ """The "unbounded by design" answer for a signed quantity.
492
+
493
+ A variance, a net change, a margin or an `A - B` difference has no legal
494
+ range, and that is a CLASSIFICATION, not a gap. infer_bound used to return
495
+ None for these β€” the same None as "no class matched" β€” so the post-load
496
+ gate posted NET_CASH_FLOW_USD, GROSS_MARGIN, OPERATING_MARGIN and
497
+ SPEND_VARIANCE_USD as vocabulary gaps on every build that had them
498
+ (2026-10-12..15). `hard=False` and both ends open, so no layer clamps,
499
+ reports or piles up on it; see MeasureBound.unbounded.
500
+ """
501
+ return MeasureBound(None, None, "signed", why, hard=False)
502
+
503
+
504
  def infer_bound(
505
  name: str,
506
  fmt: str = "float",
 
509
  signed: bool = False,
510
  formula: str = "",
511
  ) -> MeasureBound | None:
512
+ """The legal range for a measure, or None when NO CLASS MATCHED its name.
513
+
514
+ None is the "our vocabulary missed this name" answer, reported as
515
+ `bound_not_inferable`. A measure that has no range BECAUSE of what it is
516
+ (a variance, a net change, a margin, a difference formula) is not None: it
517
+ is a `signed` MeasureBound with both ends open β€” see _signed_bound.
518
 
519
  Precedence: a physical/market unit beats a name class (a SPOT_PRICE_EUR_MWH
520
  is capped by the market, not merely non-negative), then proportion, then
 
546
  # (UNIT_MARKET_SHARE = POS_UNITS_SOLD / CATEGORY_TOTAL_UNITS).
547
  units_cancel = _ratio_units_cancel(formula)
548
  is_quantity = is_quantity_unit_name(name) and not units_cancel
549
+ if is_open_ratio_name(name):
550
+ # A ratio of two different quantities (coverage, combined, turnover,
551
+ # leverage, book-to-bill): a floor of 0 and NO ceiling, whatever the
552
+ # declared fmt says. Reading it as a part over its whole pinned a
553
+ # pipeline coverage ratio at 99.8% (C1, decision 3). `hard=False`: the
554
+ # floor is reported, never clamped.
555
+ return MeasureBound(
556
+ 0.0, None, "non_negative",
557
+ "an open ratio of two quantities β€” a floor of 0 and no ceiling", hard=False,
558
+ )
559
  if (
560
  (is_proportion_column(name, units_cancel=units_cancel) and not is_quantity)
561
  or (fmt == "pct" and not is_quantity)
 
575
  return _score_bound(name, params)
576
 
577
  if name_signed:
578
+ # A variance / delta / error has no semantic bound β€” by design, which
579
+ # is an answer, not a gap.
580
+ return _signed_bound(
581
+ "declared signed in the blueprint" if signed
582
+ else "named as a signed quantity (a variance, change, margin, flow or error)"
583
+ )
584
+ if is_net_name(name):
585
+ return _signed_bound("a NET_ quantity is the difference of two amounts")
586
 
587
  # An explicit token wins over the formula's shape. REVENUE_LEAKAGE_USD was
588
  # derived as a difference and went negative in 37% of rows; "leakage" is a
 
594
  0.0, None, "non_negative", f"'{named[0].lower()}' cannot be negative"
595
  )
596
 
597
+ # Weak, format-derived non-negativity: a count or an amount of money β€” by
598
+ # the declared fmt, or by a currency code in the name (TOTAL_DOLLARS,
599
+ # ZORKMIDS_CAD: the currency says it is money even when the noun is
600
+ # unknown). This one DOES yield to a formula that is a genuine difference
601
+ # (a profit, a net change), because the format alone says nothing about
602
+ # meaning.
603
+ currency = next((t for t in toks if t in CURRENCY_TOKENS), "")
604
+ if fmt in ("int", "currency") or currency:
605
  if formula:
606
  from demoprep_app.dataset.formula import is_signed as _formula_is_signed
607
 
608
  if _formula_is_signed(formula):
609
+ return _signed_bound(f"the formula '{formula}' is a difference")
610
+ if fmt == "int":
611
+ return MeasureBound(0.0, None, "non_negative", "an int measure cannot be negative", hard=True)
612
+ why = (f"a {fmt} measure cannot be negative" if fmt == "currency"
613
+ else f"an amount in {currency} cannot be negative")
614
+ return MeasureBound(0.0, None, "non_negative", why, hard=False)
615
 
616
  return None
617
 
demoprep_app/dataset/measure_names.py CHANGED
@@ -271,12 +271,60 @@ _PROPORTION_TOKENS = frozenset({
271
  "UTILIZATION", "UTILISATION", "OCCUPANCY", "ADHERENCE", "COMPLIANCE",
272
  "ATTAINMENT", "PENETRATION", "PROBABILITY", "PROB", "PROPORTION",
273
  "FILLRATE", "COVERAGE",
 
 
 
 
 
 
274
  })
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  #: A bounded score/index/rating. The RANGE must be declared or come from a
277
  #: standard scale (measure_bounds owns the scale ladder); there is no universal
278
- #: default and we do not invent one.
279
- _SCORE_TOKENS = frozenset({"SCORE", "INDEX", "RATING", "GRADE", "CSAT", "NPS", "CES"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
  #: Magnitude UNITS. A name carrying one of these is a QUANTITY, not a
282
  #: proportion, even when it also carries a proportion word: UTILIZATION_HOURS
@@ -287,7 +335,60 @@ _QUANTITY_UNIT_TOKENS = frozenset({
287
  "MW", "MWH", "KW", "KWH", "GWH", "TONNE", "TONNES", "TON", "TONS",
288
  "MILE", "MILES", "KM", "LITRE", "LITRES", "LITER", "LITERS", "GALLON",
289
  "GALLONS", "BBL", "SQFT", "SQM", "UNIT", "UNITS", "HEADCOUNT", "FTE",
290
- "USD", "EUR", "GBP", "CHF", "SEK", "NOK", "DKK", "PLN",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  })
292
 
293
  #: Quantities that cannot be negative: money spent/lost, counts, durations.
@@ -304,7 +405,7 @@ _NON_NEGATIVE_TOKENS = frozenset({
304
  "REVENUE", "SALES", "BILLINGS", "BOOKINGS", "PAYMENTS", "COLLECTIONS",
305
  "PRICE", "AMOUNT", "CHARGES", "PREMIUM", "CLAIMS", "HEADCOUNT",
306
  "CAPACITY", "INVENTORY", "STOCK", "TONNES", "TONS", "KWH", "MWH",
307
- })
308
 
309
  #: A measure that may legitimately go negative: a variance, an error, a net
310
  #: change. These VETO the non-negative class and widen a proportion to
@@ -316,8 +417,14 @@ _SIGNED_TOKENS = frozenset({
316
  "PNL", "PL", "BALANCE", "FLOW",
317
  })
318
 
 
 
 
 
 
 
319
  #: Currencies, for the market-price registry in measure_bounds.
320
- CURRENCY_TOKENS = frozenset({"EUR", "USD", "GBP", "CHF", "SEK", "NOK", "DKK", "PLN"})
321
 
322
  _CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
323
  _NON_ALNUM = re.compile(r"[^A-Za-z0-9]+")
@@ -372,10 +479,15 @@ def is_proportion_column(column_name: str, *, units_cancel: bool = False) -> boo
372
  UNIT_MARKET_SHARE = POS_UNITS_SOLD / CATEGORY_TOTAL_UNITS is a share of
373
  units. The price and additive vetoes still apply.
374
  """
 
 
375
  if is_rate_column(column_name):
376
  return True
377
  toks = name_tokens(column_name)
378
- if not _has_token(_PROPORTION_TOKENS, toks):
 
 
 
379
  return False
380
  # The same two vetoes `is_intensive_measure` applies, from the same
381
  # predicates: a price that happens to contain "rate", and an explicitly
@@ -405,8 +517,31 @@ def rate_scale_for_name(column_name: str) -> float:
405
  return 1.0
406
 
407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  def is_score_column(column_name: str) -> bool:
409
- """True for a bounded score/index/rating (the RANGE lives elsewhere)."""
 
 
 
 
 
 
410
  return _has_token(_SCORE_TOKENS, name_tokens(column_name))
411
 
412
 
@@ -415,6 +550,24 @@ def is_signed_name(column_name: str) -> bool:
415
  return _has_token(_SIGNED_TOKENS, name_tokens(column_name))
416
 
417
 
 
 
 
 
 
 
 
 
418
  def non_negative_tokens_in(column_name: str) -> list[str]:
419
- """The tokens that say this measure cannot be negative, in name order."""
420
- return [t for t in name_tokens(column_name) if t in _NON_NEGATIVE_TOKENS]
 
 
 
 
 
 
 
 
 
 
 
271
  "UTILIZATION", "UTILISATION", "OCCUPANCY", "ADHERENCE", "COMPLIANCE",
272
  "ATTAINMENT", "PENETRATION", "PROBABILITY", "PROB", "PROPORTION",
273
  "FILLRATE", "COVERAGE",
274
+ # findings triage 2026-10-15 (A5): THROUGHPUT_EFFICIENCY,
275
+ # LEAD_ATTRIBUTION_COMPLETENESS, SEAT_AVAILABILITY, FORECAST_ACCURACY,
276
+ # CUSTOMER_RETENTION, LEAD_CONVERSION and UPTIME are proportions of a whole
277
+ # (a magnitude unit next to them still wins: FUEL_EFFICIENCY_MPG).
278
+ "EFFICIENCY", "COMPLETENESS", "AVAILABILITY", "ACCURACY", "RETENTION",
279
+ "CONVERSION", "UPTIME",
280
  })
281
 
282
+ #: Proportion words that are two tokens long. Matched on the underscored name,
283
+ #: since `name_tokens` cannot see a phrase.
284
+ _PROPORTION_PHRASES = ("LOAD_FACTOR", "SELL_THROUGH", "CLICK_THROUGH")
285
+
286
+ #: A RATIO that legitimately exceeds 1 β€” a ratio of two different quantities,
287
+ #: not a part over its whole (findings triage 2026-10-15, C1, decision 3: the
288
+ #: `_RATIO` suffix keeps its 0-1 default and these are the veto). Bruker's
289
+ #: PIPELINE_COVERAGE_RATIO was pinned at 99.8% on a shipped board; an insurance
290
+ #: COMBINED_RATIO over 100% IS the story. Consulted only when the name also
291
+ #: says RATIO / MULTIPLE / TURNS, so TURNOVER_RATE (15% of staff) stays a rate
292
+ #: while INVENTORY_TURNOVER_RATIO (5x) is open.
293
+ _OPEN_RATIO_TOKENS = frozenset({
294
+ "COVERAGE", "COMBINED", "TURNOVER", "LEVERAGE", "MULTIPLE", "MULTIPLES",
295
+ "TURNS", "CURRENT", "QUICK", "LOSS", "EXPENSE", "SHARPE", "SORTINO",
296
+ })
297
+ _OPEN_RATIO_PHRASES = (
298
+ "BOOK_TO_BILL", "LTV_CAC", "LTV_TO_CAC", "DEBT_TO", "PRICE_TO", "LOAN_TO_VALUE",
299
+ "DEBT_SERVICE_COVERAGE", "INTEREST_COVERAGE", "PIPELINE_COVERAGE", "QUOTA_COVERAGE",
300
+ "INVENTORY_TURNS", "ASSET_TURNS",
301
+ )
302
+ _RATIO_WORD_TOKENS = frozenset({"RATIO", "RATIOS", "MULTIPLE", "MULTIPLES", "TURNS"})
303
+
304
  #: A bounded score/index/rating. The RANGE must be declared or come from a
305
  #: standard scale (measure_bounds owns the scale ladder); there is no universal
306
+ #: default and we do not invent one. An explicitly additive shape is never a
307
+ #: score: CSAT_SCORE_SUM is a SUM of scores, a quantity.
308
+ _SCORE_TOKENS = frozenset({"SCORE", "INDEX", "RATING", "GRADE", "CSAT", "NPS", "CES", "GPA"})
309
+
310
+ #: ISO-4217 codes and the plain words, for the market-price registry in
311
+ #: measure_bounds and as magnitude units below. Five Canadian prospects in
312
+ #: three days (OMERS, Loblaw, Honda Canada, Islamic Relief Canada, Environics)
313
+ #: had every _CAD column unclassified (findings triage 2026-10-15, A3).
314
+ _CURRENCY_CODES = frozenset({
315
+ "USD", "EUR", "GBP", "CHF", "SEK", "NOK", "DKK", "PLN",
316
+ "CAD", "AUD", "NZD", "JPY", "INR", "MXN", "BRL", "SGD", "HKD", "ZAR",
317
+ "DOLLARS", "EUROS", "POUNDS",
318
+ })
319
+
320
+ #: Physical units that are also non-negative magnitudes (added to both sets).
321
+ _PHYSICAL_UNIT_TOKENS = frozenset({
322
+ "FT", "FEET", "CM", "MM", "CM2", "M2", "KM2", "ACRES", "HECTARES",
323
+ "KG", "KGS", "LB", "LBS", "OZ", "BUSHELS", "BARRELS",
324
+ "MS", "MSEC", "WEEK", "WEEKS", "MONTH", "MONTHS", "YEAR", "YEARS",
325
+ "MILLIONS", "THOUSANDS", "BILLIONS",
326
+ "MPG", "KMPL", "MPGE", "RPM", "PSI", "GB", "TB", "MB", "MBPS", "GBPS",
327
+ })
328
 
329
  #: Magnitude UNITS. A name carrying one of these is a QUANTITY, not a
330
  #: proportion, even when it also carries a proportion word: UTILIZATION_HOURS
 
335
  "MW", "MWH", "KW", "KWH", "GWH", "TONNE", "TONNES", "TON", "TONS",
336
  "MILE", "MILES", "KM", "LITRE", "LITRES", "LITER", "LITERS", "GALLON",
337
  "GALLONS", "BBL", "SQFT", "SQM", "UNIT", "UNITS", "HEADCOUNT", "FTE",
338
+ # basis points are a scaled proportion with neither a 0-1 nor a 0-100
339
+ # ceiling; as a unit they are left unbounded rather than clamped wrong
340
+ "BPS",
341
+ }) | _CURRENCY_CODES | _PHYSICAL_UNIT_TOKENS
342
+
343
+ #: Counts: plural business nouns and the past participles that name a counted
344
+ #: event (ORDERS_SHIPPED, TICKETS_RESOLVED, CASES_PICKED). Until 2026-10-20
345
+ #: there was no count vocabulary at all, so every one of these came back from
346
+ #: the post-load gate as "no bound class matched" (findings triage, A3).
347
+ _COUNT_NOUN_TOKENS = frozenset({
348
+ "SESSIONS", "ORDERS", "VISITS", "TICKETS", "CASES", "LEADS", "OPPORTUNITIES",
349
+ "DEALS", "SHIPMENTS", "LOADS", "TRIPS", "CALLS", "MESSAGES", "EMAILS", "CLICKS",
350
+ "IMPRESSIONS", "VIEWS", "PAGEVIEWS", "DOWNLOADS", "SIGNUPS", "USERS", "CUSTOMERS",
351
+ "ACCOUNTS", "PATIENTS", "ENCOUNTERS", "ADMISSIONS", "DISCHARGES", "STUDENTS",
352
+ "APPLICATIONS", "ENROLLMENTS", "EMPLOYEES", "HIRES", "ITEMS", "PRODUCTS", "SKUS",
353
+ "TRANSACTIONS", "INVOICES", "REFUNDS", "COMPLAINTS", "ESCALATIONS", "RESERVATIONS",
354
+ "PASSENGERS", "FLIGHTS", "ROOMS", "NIGHTS", "STAYS", "GUESTS", "DELIVERIES",
355
+ "PICKUPS", "STOPS", "ROUTES", "VEHICLES", "DRIVERS", "ASSETS", "DEVICES",
356
+ "MACHINES", "CYCLES", "BATCHES", "LOTS", "PALLETS", "CONTAINERS", "PARCELS",
357
+ "PACKAGES", "EVENTS", "ALERTS", "TASKS", "JOBS", "PROJECTS", "CONTRACTS",
358
+ "POLICIES", "QUOTES", "BIDS", "SUBMISSIONS", "APPROVALS", "REJECTIONS",
359
+ "CANCELLATIONS", "RENEWALS", "ACTIVATIONS", "INSTALLS", "SUBSCRIPTIONS",
360
+ "MEMBERS", "SUBSCRIBERS", "ATTENDEES", "REGISTRATIONS", "APPOINTMENTS",
361
+ "PROCEDURES", "SAMPLES", "PIECES", "PARTS", "COMPONENTS", "ERRORS", "ISSUES",
362
+ "LOANS", "TRADES", "LISTINGS", "LEASES", "TENANTS", "PROPERTIES", "BEDS",
363
+ "SEATS", "SLOTS", "SEARCHES", "QUERIES", "PROMPTS", "TOKENS", "REQUESTS",
364
+ "LOGINS", "ATTEMPTS", "FAILURES", "RETRIES", "TIMEOUTS", "RELEASES",
365
+ "DEPLOYMENTS", "VISITORS", "SHOPPERS", "BUYERS", "SELLERS", "VENDORS",
366
+ "SUPPLIERS", "CARRIERS", "PICKS", "SCANS", "RETURNS_COUNT",
367
+ })
368
+ _COUNT_EVENT_TOKENS = frozenset({
369
+ "CREATED", "RESOLVED", "SHIPPED", "PICKED", "PACKED", "OPENED", "CLOSED",
370
+ "SUBMITTED", "APPROVED", "REJECTED", "RETURNED", "CANCELLED", "CANCELED",
371
+ "COMPLETED", "DELIVERED", "PROCESSED", "HANDLED", "BOOKED", "FILLED",
372
+ "CONVERTED", "WON", "LOST", "ESCALATED", "ENROLLED", "HIRED", "TERMINATED",
373
+ "STARTED", "FINISHED", "PLACED", "RECEIVED", "SENT", "VISITED", "ATTENDED",
374
+ "REGISTERED", "ACTIVATED", "CHURNED", "RENEWED", "ISSUED", "PAID", "SOLD",
375
+ "SERVED", "DISPATCHED", "LOADED", "UNLOADED", "INSPECTED", "REPAIRED",
376
+ "TESTED", "PASSED", "SCHEDULED", "LAUNCHED", "SIGNED", "ONBOARDED",
377
+ "ANSWERED", "ABANDONED", "FULFILLED", "INSTALLED", "DISCHARGED", "ADMITTED",
378
+ })
379
+
380
+ #: Amounts of money, by noun. COGS, GMV, AUM, COMMISSION, DISCOUNT, FARE, MSRP,
381
+ #: ASP, BUDGET, PAYROLL ... were all unclassified before 2026-10-20 (A3).
382
+ _MONEY_NOUN_TOKENS = frozenset({
383
+ "COGS", "GMV", "AUM", "VALUE", "COMMISSION", "COMMISSIONS", "DISCOUNT",
384
+ "DISCOUNTS", "FARE", "FARES", "INCENTIVE", "INCENTIVES", "MSRP", "ASP",
385
+ "CAPITAL", "INCOME", "PIPELINE", "RESERVES", "COMPENSATION", "SAVINGS",
386
+ "AID", "BUDGET", "PAY", "PAYROLL", "SURCHARGE", "SURCHARGES", "TARIFF",
387
+ "TARIFFS", "TOLL", "TOLLS", "LTV", "CAC", "REIMBURSEMENT", "REIMBURSEMENTS",
388
+ "WAGES", "SALARY", "SALARIES", "RENT", "DEPOSIT", "DEPOSITS", "DONATIONS",
389
+ "GRANTS", "FUNDING", "SUBSIDY", "SUBSIDIES", "REBATE", "REBATES", "ROYALTY",
390
+ "ROYALTIES", "TUITION", "PREMIUMS", "PAYOUT", "PAYOUTS", "DISBURSEMENTS",
391
+ "PROCEEDS", "EARNINGS", "TURNOVER", "GTV", "TPV",
392
  })
393
 
394
  #: Quantities that cannot be negative: money spent/lost, counts, durations.
 
405
  "REVENUE", "SALES", "BILLINGS", "BOOKINGS", "PAYMENTS", "COLLECTIONS",
406
  "PRICE", "AMOUNT", "CHARGES", "PREMIUM", "CLAIMS", "HEADCOUNT",
407
  "CAPACITY", "INVENTORY", "STOCK", "TONNES", "TONS", "KWH", "MWH",
408
+ }) | _COUNT_NOUN_TOKENS | _COUNT_EVENT_TOKENS | _MONEY_NOUN_TOKENS | _PHYSICAL_UNIT_TOKENS
409
 
410
  #: A measure that may legitimately go negative: a variance, an error, a net
411
  #: change. These VETO the non-negative class and widen a proportion to
 
417
  "PNL", "PL", "BALANCE", "FLOW",
418
  })
419
 
420
+ #: A LEADING "NET" makes a quantity a difference by definition β€” NET_INCOME,
421
+ #: NET_CASH_POSITION, NET_ENROLLMENT β€” whatever token follows. Position
422
+ #: matters, and it is consulted only for a measure that is neither a proportion
423
+ #: nor a score (NET_COLLECTION_RATE is a 0-1 rate), so it never widens a rate.
424
+ _SIGNED_LEADING_TOKENS = frozenset({"NET"})
425
+
426
  #: Currencies, for the market-price registry in measure_bounds.
427
+ CURRENCY_TOKENS = _CURRENCY_CODES
428
 
429
  _CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
430
  _NON_ALNUM = re.compile(r"[^A-Za-z0-9]+")
 
479
  UNIT_MARKET_SHARE = POS_UNITS_SOLD / CATEGORY_TOTAL_UNITS is a share of
480
  units. The price and additive vetoes still apply.
481
  """
482
+ if is_open_ratio_name(column_name):
483
+ return False # a ratio of two different things has no whole
484
  if is_rate_column(column_name):
485
  return True
486
  toks = name_tokens(column_name)
487
+ normalized = f"_{_normalize(column_name)}_"
488
+ if not _has_token(_PROPORTION_TOKENS, toks) and not any(
489
+ f"_{phrase}_" in normalized for phrase in _PROPORTION_PHRASES
490
+ ):
491
  return False
492
  # The same two vetoes `is_intensive_measure` applies, from the same
493
  # predicates: a price that happens to contain "rate", and an explicitly
 
517
  return 1.0
518
 
519
 
520
+ def is_open_ratio_name(column_name: str) -> bool:
521
+ """True for a RATIO of two different quantities, which legitimately exceeds 1.
522
+
523
+ PIPELINE_COVERAGE_RATIO (3.2x), COMBINED_RATIO (104%), INVENTORY_TURNOVER_RATIO
524
+ (5x), BOOK_TO_BILL, LTV_CAC, DEBT_TO_EQUITY_RATIO, CURRENT_RATIO. These keep
525
+ the `_RATIO` AVERAGE aggregation (is_rate_column is untouched) but take a
526
+ floor of 0 and no ceiling instead of the 0-1 default β€” reading them as a
527
+ part over its whole pinned Bruker's pipeline coverage at 99.8% on a shipped
528
+ board (findings triage 2026-10-15, C1).
529
+ """
530
+ normalized = f"_{_normalize(column_name)}_"
531
+ if any(f"_{phrase}_" in normalized for phrase in _OPEN_RATIO_PHRASES):
532
+ return True
533
+ toks = name_tokens(column_name)
534
+ return _has_token(_RATIO_WORD_TOKENS, toks) and _has_token(_OPEN_RATIO_TOKENS, toks)
535
+
536
+
537
  def is_score_column(column_name: str) -> bool:
538
+ """True for a bounded score/index/rating (the RANGE lives elsewhere).
539
+
540
+ An explicitly additive shape (_SUM, _TOTAL, TOTAL_, _COUNT) is never a
541
+ score: CSAT_SCORE_SUM is a sum of 1-5 ratings, a quantity with no scale.
542
+ """
543
+ if is_explicitly_additive_name(column_name):
544
+ return False
545
  return _has_token(_SCORE_TOKENS, name_tokens(column_name))
546
 
547
 
 
550
  return _has_token(_SIGNED_TOKENS, name_tokens(column_name))
551
 
552
 
553
+ def is_net_name(column_name: str) -> bool:
554
+ """True when the NAME opens with NET: the difference of two amounts, so it
555
+ may legitimately be negative. Consulted by measure_bounds.infer_bound only
556
+ for a measure that is neither a proportion nor a score."""
557
+ toks = name_tokens(column_name)
558
+ return bool(toks) and toks[0] in _SIGNED_LEADING_TOKENS
559
+
560
+
561
  def non_negative_tokens_in(column_name: str) -> list[str]:
562
+ """The tokens that say this measure cannot be negative, in name order.
563
+
564
+ A SUM / TOTAL / COUNT of scores or ratings (CSAT_SCORE_SUM) is a quantity:
565
+ the additive token is the evidence, unless the score is an NPS, whose sum
566
+ can be negative.
567
+ """
568
+ toks = name_tokens(column_name)
569
+ found = [t for t in toks if t in _NON_NEGATIVE_TOKENS]
570
+ if (not found and is_explicitly_additive_name(column_name)
571
+ and _has_token(_SCORE_TOKENS, toks) and "NPS" not in toks):
572
+ found = [t for t in toks if t in ("SUM", "TOTAL", "COUNT")] or ["SUM"]
573
+ return found
demoprep_app/dataset/validator.py CHANGED
@@ -26,15 +26,30 @@ from demoprep_app.scenario.blueprint import DemoBlueprint, PlantedInsight
26
  # An effect must realize at least this fraction of its promised magnitude
27
  # to count as demo-visible (noise eats some of it; that's fine).
28
  MIN_REALIZED_FRACTION = 0.5
 
 
 
 
 
29
 
30
 
31
  @dataclass(slots=True)
32
  class InsightCheck:
33
  insight_id: str
34
  passed: bool
 
35
  observed_ratio: float
 
36
  required_ratio: float
37
  detail: str
 
 
 
 
 
 
 
 
38
 
39
 
40
  @dataclass(slots=True)
@@ -89,6 +104,9 @@ class ValidationReport:
89
  def failed_insights(self) -> list[InsightCheck]:
90
  return [c for c in self.insight_checks if not c.passed]
91
 
 
 
 
92
  def warning_findings(self) -> list[Finding]:
93
  return [f for f in self.findings if f.severity == "warning"]
94
 
@@ -98,8 +116,11 @@ class ValidationReport:
98
  lines += [f" formula: {e}" for e in self.formula_errors]
99
  lines += [f" range: {e}" for e in self.range_errors]
100
  for c in self.insight_checks:
101
- status = "ok" if c.passed else "WEAK"
102
- lines.append(f" insight {c.insight_id}: {status} observed={c.observed_ratio:.2f} required={c.required_ratio:.2f} ({c.detail})")
 
 
 
103
  return "\n".join(lines)
104
 
105
 
@@ -170,21 +191,59 @@ def _record_findings(report: ValidationReport) -> None:
170
  ),
171
  ))
172
  for check in report.failed_insights():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  report.findings.append(Finding(
174
  code="insight_weak",
175
  severity="warning",
176
  stage="validation",
177
  message=(
178
- f"planted insight {check.insight_id} realized {check.observed_ratio:.2f} of the "
179
- f"{check.required_ratio:.2f} it promised ({check.detail})"
 
 
180
  ),
181
  suggestion=(
182
  "raise the insight's magnitude or the mix_weight of its target dimension value "
183
  "so the chart is visibly different from the baseline"
184
  ),
185
- observed=f"{check.observed_ratio:.2f}",
186
- expected=f"{check.required_ratio:.2f}",
187
- extra={"insight_id": check.insight_id},
 
 
 
 
 
 
188
  ))
189
 
190
 
@@ -384,6 +443,16 @@ def _check_insight(
384
  promised = insight.magnitude
385
  laggard = promised < 1.0
386
  required = 1.0 + (promised - 1.0) * MIN_REALIZED_FRACTION # works for both directions
 
 
 
 
 
 
 
 
 
 
387
 
388
  start = _safe_date(insight.window_start)
389
  end = _safe_date(insight.window_end)
@@ -432,15 +501,17 @@ def _check_insight(
432
  ]
433
  detail = "window vs rest of history"
434
  else:
435
- return InsightCheck(insight.id, False, 0.0, required, f"unknown insight_type {insight.insight_type}")
436
-
437
- if len(target) < 20 or len(others) < 20:
438
- return InsightCheck(
439
- insight.id, False, 0.0, required,
440
- f"too few rows to demo ({len(target)} target / {len(others)} baseline) β€” "
441
- f"raise mix_weight for {insight.dimension_value} or widen the window",
 
 
442
  )
443
 
444
  observed = mean(target) / mean(others) if mean(others) else 0.0
445
  passed = observed <= required if laggard else observed >= required
446
- return InsightCheck(insight.id, passed, observed, required, detail)
 
26
  # An effect must realize at least this fraction of its promised magnitude
27
  # to count as demo-visible (noise eats some of it; that's fine).
28
  MIN_REALIZED_FRACTION = 0.5
29
+ # Rows needed on EACH side of an insight's comparison before it can be
30
+ # measured at all. Below this the check is not "weak", it is unmeasurable
31
+ # (findings triage 2026-10-15, B3) β€” the blueprint sizes segments to clear it
32
+ # before generation (DemoBlueprint.ensure_insight_support).
33
+ MIN_ROWS_PER_SIDE = 20
34
 
35
 
36
  @dataclass(slots=True)
37
  class InsightCheck:
38
  insight_id: str
39
  passed: bool
40
+ #: target / baseline as measured in the generated rows
41
  observed_ratio: float
42
+ #: the pass bar: 1 + (promised - 1) x MIN_REALIZED_FRACTION β€” NOT the promise
43
  required_ratio: float
44
  detail: str
45
+ #: the magnitude the blueprint stated (before any tuning boost)
46
+ promised_ratio: float = 0.0
47
+ #: "above baseline" (outlier, > 1) or "below baseline" (laggard, < 1)
48
+ direction: str = ""
49
+ #: False when the comparison could not be made at all (too few rows on a
50
+ #: side, an unknown insight type) β€” `observed_ratio` is then meaningless
51
+ #: and the finding is `insight_unmeasurable`, not `insight_weak`
52
+ measurable: bool = True
53
 
54
 
55
  @dataclass(slots=True)
 
104
  def failed_insights(self) -> list[InsightCheck]:
105
  return [c for c in self.insight_checks if not c.passed]
106
 
107
+ def unmeasurable_insights(self) -> list[InsightCheck]:
108
+ return [c for c in self.insight_checks if not c.measurable]
109
+
110
  def warning_findings(self) -> list[Finding]:
111
  return [f for f in self.findings if f.severity == "warning"]
112
 
 
116
  lines += [f" formula: {e}" for e in self.formula_errors]
117
  lines += [f" range: {e}" for e in self.range_errors]
118
  for c in self.insight_checks:
119
+ status = "ok" if c.passed else ("UNMEASURABLE" if not c.measurable else "WEAK")
120
+ lines.append(
121
+ f" insight {c.insight_id}: {status} observed={c.observed_ratio:.2f} "
122
+ f"required={c.required_ratio:.2f} promised={c.promised_ratio:.2f} ({c.detail})"
123
+ )
124
  return "\n".join(lines)
125
 
126
 
 
191
  ),
192
  ))
193
  for check in report.failed_insights():
194
+ if not check.measurable:
195
+ # Not a weak effect β€” no effect could be measured. A distinct code,
196
+ # with the cause, so it is never read as "the chart is subtle".
197
+ report.findings.append(Finding(
198
+ code="insight_unmeasurable",
199
+ severity="warning",
200
+ stage="validation",
201
+ message=f"planted insight {check.insight_id} could not be measured β€” {check.detail}",
202
+ suggestion=(
203
+ "raise mix_weight for the target dimension value, or move window_start "
204
+ "inside the generated history (its last third), so both sides of the "
205
+ f"comparison have at least {MIN_ROWS_PER_SIDE} rows"
206
+ ),
207
+ observed="not measurable",
208
+ expected=f">= {MIN_ROWS_PER_SIDE} rows on each side",
209
+ extra={
210
+ "insight_id": check.insight_id,
211
+ "promised_ratio": check.promised_ratio,
212
+ "direction": check.direction,
213
+ },
214
+ ))
215
+ continue
216
+ # The message states all four numbers a reader needs, in the right
217
+ # roles. It used to print required_ratio as "the N it promised", which
218
+ # for a laggard read backwards: "realized 0.83 of the 0.69 it promised"
219
+ # meant "needed <= 0.69 (the blueprint promised 0.38), got 0.83" β€” a
220
+ # segment 17% below baseline where the story needs 62%. Nine of forty
221
+ # such lines (2026-10-12..15) looked like false positives and were not.
222
+ laggard = check.direction == "below baseline"
223
+ bar = "<=" if laggard else ">="
224
  report.findings.append(Finding(
225
  code="insight_weak",
226
  severity="warning",
227
  stage="validation",
228
  message=(
229
+ f"planted insight {check.insight_id} realized {check.observed_ratio:.2f}x "
230
+ f"baseline; needed {bar} {check.required_ratio:.2f}x to pass, "
231
+ f"promised {check.promised_ratio:.2f}x ({check.direction or 'direction unknown'}) "
232
+ f"β€” {check.detail}"
233
  ),
234
  suggestion=(
235
  "raise the insight's magnitude or the mix_weight of its target dimension value "
236
  "so the chart is visibly different from the baseline"
237
  ),
238
+ observed=f"{check.observed_ratio:.2f}x baseline",
239
+ expected=f"{bar} {check.required_ratio:.2f}x (promised {check.promised_ratio:.2f}x)",
240
+ extra={
241
+ "insight_id": check.insight_id,
242
+ "observed_ratio": check.observed_ratio,
243
+ "required_ratio": check.required_ratio,
244
+ "promised_ratio": check.promised_ratio,
245
+ "direction": check.direction,
246
+ },
247
  ))
248
 
249
 
 
443
  promised = insight.magnitude
444
  laggard = promised < 1.0
445
  required = 1.0 + (promised - 1.0) * MIN_REALIZED_FRACTION # works for both directions
446
+ # What the blueprint PROMISED, for the report: the magnitude as authored,
447
+ # not the figure the tuning loop has boosted it to.
448
+ authored = insight.authored_magnitude if insight.authored_magnitude is not None else promised
449
+ direction = "below baseline" if laggard else "above baseline"
450
+
451
+ def check(passed: bool, observed: float, detail: str, *, measurable: bool = True) -> InsightCheck:
452
+ return InsightCheck(
453
+ insight.id, passed, observed, required, detail,
454
+ promised_ratio=authored, direction=direction, measurable=measurable,
455
+ )
456
 
457
  start = _safe_date(insight.window_start)
458
  end = _safe_date(insight.window_end)
 
501
  ]
502
  detail = "window vs rest of history"
503
  else:
504
+ return check(False, 0.0, f"unknown insight_type {insight.insight_type}", measurable=False)
505
+
506
+ if len(target) < MIN_ROWS_PER_SIDE or len(others) < MIN_ROWS_PER_SIDE:
507
+ return check(
508
+ False, 0.0,
509
+ f"too few rows to demo ({len(target)} target / {len(others)} baseline; each side "
510
+ f"needs {MIN_ROWS_PER_SIDE}) β€” raise mix_weight for {insight.dimension_value} or "
511
+ "move the window inside the data span",
512
+ measurable=False,
513
  )
514
 
515
  observed = mean(target) / mean(others) if mean(others) else 0.0
516
  passed = observed <= required if laggard else observed >= required
517
+ return check(passed, observed, detail)
demoprep_app/integrations/snowflake/__init__.py CHANGED
@@ -5,6 +5,7 @@ from demoprep_app.integrations.snowflake.quality_gate import (
5
  MeasureQualityGateError,
6
  ZeroMeasureError,
7
  apply_measure_omissions,
 
8
  measure_formulas,
9
  measure_shares,
10
  run_measure_quality_gate,
@@ -13,6 +14,7 @@ from demoprep_app.integrations.snowflake.quality_gate import (
13
  __all__ = [
14
  "populate_dataset_bundle",
15
  "run_measure_quality_gate",
 
16
  "measure_formulas",
17
  "measure_shares",
18
  "apply_measure_omissions",
 
5
  MeasureQualityGateError,
6
  ZeroMeasureError,
7
  apply_measure_omissions,
8
+ measure_formats,
9
  measure_formulas,
10
  measure_shares,
11
  run_measure_quality_gate,
 
14
  __all__ = [
15
  "populate_dataset_bundle",
16
  "run_measure_quality_gate",
17
+ "measure_formats",
18
  "measure_formulas",
19
  "measure_shares",
20
  "apply_measure_omissions",
demoprep_app/integrations/snowflake/quality_gate.py CHANGED
@@ -77,6 +77,7 @@ from demoprep_app.dataset.measure_names import ( # noqa: F401 (re-exported)
77
  default_aggregation,
78
  is_flag_column,
79
  is_intensive_measure,
 
80
  is_rate_column,
81
  )
82
 
@@ -91,6 +92,7 @@ from demoprep_app.dataset.measure_bounds import (
91
  from demoprep_app.quality.findings import Finding
92
  from demoprep_app.dataset.formula import (
93
  FormulaEvaluationError,
 
94
  identifiers as formula_identifiers,
95
  to_sql as formula_to_sql,
96
  )
@@ -222,6 +224,31 @@ def measure_shares(bundle: Any) -> dict[str, str]:
222
  return shares
223
 
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  def apply_measure_omissions(bundle: Any, omissions: Iterable[Mapping[str, str]]) -> list[str]:
226
  """Remove omitted measure columns from an in-memory DatasetBundle.
227
 
@@ -356,23 +383,50 @@ def _mirror_defects(
356
  for idx, (col, other, sign) in enumerate(candidates):
357
  differing = int(row[idx] or 0)
358
  if differing == 0:
 
 
359
  defects.append({
360
  "table": table_name,
361
  "column": col,
362
  "defect": "mirrors_other_column",
363
- "detail": (
364
- f"every row equals {'-' if sign < 0 else ''}{other} β€” the formula's other "
365
- f"input resolved to nothing"
366
- ),
367
- "suggestion": (
368
- f"{col}'s formula references an identifier that is not a measure of "
369
- f"{table_name}; fix the formula's inputs in the blueprint so it computes "
370
- "something other than a copy of another column"
371
- ),
372
  })
373
  return defects
374
 
375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  def _part_whole_defects(
377
  cursor: Any,
378
  schema_name: str,
@@ -380,6 +434,7 @@ def _part_whole_defects(
380
  stats: dict[str, dict],
381
  formulas: Mapping[str, str],
382
  shares: Mapping[str, str],
 
383
  ) -> list[dict]:
384
  """part > whole for every PART / WHOLE pair a ratio formula implies.
385
 
@@ -402,7 +457,11 @@ def _part_whole_defects(
402
  tbl, _, ratio_col = key.partition(".")
403
  if tbl != table_name:
404
  continue
405
- ratio_bound = semantic_bound(ratio_col, stats.get(ratio_col, {}), formula)
 
 
 
 
406
  for part, whole in part_whole_pairs(formula, ratio_bound, shares=table_shares):
407
  if part in stats and whole in stats and (part, whole, ratio_col) not in pairs:
408
  pairs.append((part, whole, ratio_col))
@@ -435,22 +494,39 @@ def _part_whole_defects(
435
  return defects
436
 
437
 
438
- def semantic_bound(column_name: str, stats: dict, formula: str = "") -> MeasureBound | None:
439
- """The legal range for a loaded column, inferred from its name + profile.
440
-
441
- Post-load we have no blueprint, so the observed AVG stands in for the
442
- declared level: it is what tells a 0-100 percentage from a 0-1 rate, and
443
- which standard scale a score sits on. fmt is left unknown on purpose β€” the
444
- format-derived "money cannot be negative" class would flag every legitimate
445
- negative margin column in the schema.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  """
447
- return infer_bound(column_name, "", {"mean": stats.get("avg")}, formula=formula)
 
 
448
 
449
 
450
  def _column_defects(
451
  table_name: str,
452
  stats: dict[str, dict],
453
  formulas: Mapping[str, str] | None = None,
 
454
  ) -> tuple[list[dict], list[str], list[Finding]]:
455
  """Per-column defects, warnings and structured findings from the profile.
456
 
@@ -460,6 +536,7 @@ def _column_defects(
460
  strings are their rendering.
461
  """
462
  formulas = formulas or {}
 
463
  defects: list[dict] = []
464
  warnings: list[str] = []
465
  findings: list[Finding] = []
@@ -494,14 +571,20 @@ def _column_defects(
494
  f"generate {col} as 0/1 only, or drop the _FLAG suffix if it is really a count"
495
  ),
496
  })
497
- semantic = semantic_bound(col, s, formulas.get(f"{table_name}.{col}", ""))
 
 
 
 
498
  if semantic is None:
499
  # No bound class matched this name, so NOTHING could be range-checked.
500
  # That is the "our inference missed a vocabulary" signal; it is not a
501
- # defect in the data, and it is not silently ignored either.
 
 
502
  findings.append(Finding(
503
  code="bound_not_inferable",
504
- severity="warning",
505
  stage="post_load",
506
  message=f"no bound class matched {col}, so no range was checked for it",
507
  suggestion=suggestion_for_unclassified(col),
@@ -515,7 +598,7 @@ def _column_defects(
515
  )
516
  findings.append(Finding(
517
  code="bound_not_inferable",
518
- severity="warning",
519
  stage="post_load",
520
  message=f"{semantic.gap} β€” no bound could be checked for this column",
521
  suggestion=semantic.suggestion or semantic.suggest_fix(col),
@@ -670,7 +753,14 @@ def _repair_statements(
670
  ))
671
  return statements
672
 
673
- # all_zero / constant / mirrors_other_column: only a formula can fix these.
 
 
 
 
 
 
 
674
  return recompute()
675
 
676
 
@@ -761,7 +851,9 @@ def _run_semantic_checks(
761
  for table_name, column_name, data_type in rows:
762
  dt = data_type or ""
763
  rng = bounded_range_for_column(column_name)
764
- if rng and is_measure_column(column_name, dt):
 
 
765
  bounded.setdefault(table_name, []).append((column_name, rng[0], rng[1]))
766
  if is_month_bucket_column(column_name, dt):
767
  month_cols.setdefault(table_name, []).append(column_name)
@@ -1052,6 +1144,7 @@ def run_measure_quality_gate(
1052
  shares: Optional[Mapping[str, str]] = None,
1053
  required_measures: Optional[Iterable[str]] = None,
1054
  fact_tables: Optional[Iterable[str]] = None,
 
1055
  repair: bool = True,
1056
  drop_unrepairable: bool = True,
1057
  ) -> dict:
@@ -1064,6 +1157,10 @@ def run_measure_quality_gate(
1064
  measure_shares(bundle). The structural half of the part <= whole
1065
  check: a declared part is verified against its whole, and a declared
1066
  part in a ratio's denominator is never read as a part of its numerator.
 
 
 
 
1067
  required_measures: measure names (bare or "TABLE.COLUMN") the brief's core
1068
  claim depends on β€” the planted insights' measures, the headline of the
1069
  story. The gate hard-fails when ANY of them ends up unusable, or when
@@ -1100,6 +1197,7 @@ def run_measure_quality_gate(
1100
 
1101
  formulas = dict(formulas or {})
1102
  shares = dict(shares or {})
 
1103
  required = {str(m).upper() for m in (required_measures or ())}
1104
 
1105
  cursor = connection.cursor()
@@ -1167,14 +1265,15 @@ def run_measure_quality_gate(
1167
  "avg": stats["avg"],
1168
  }
1169
  column_defects, column_warnings, column_findings = _column_defects(
1170
- table_name, table_profile["columns"], formulas
1171
  )
1172
  profile["defects"].extend(column_defects)
1173
  profile["warnings"].extend(column_warnings)
1174
  profile["findings"].extend(f.to_dict() for f in column_findings)
1175
  profile["defects"].extend(
1176
  _part_whole_defects(
1177
- cursor, schema_name, table_name, table_profile["columns"], formulas, shares
 
1178
  )
1179
  )
1180
  profile["defects"].extend(
@@ -1260,11 +1359,14 @@ def run_measure_quality_gate(
1260
  "max": stats["max"],
1261
  "avg": stats["avg"],
1262
  }
1263
- column_defects, _, _ = _column_defects(table_name, table_profile["columns"], formulas)
 
 
1264
  remaining.extend(column_defects)
1265
  remaining.extend(
1266
  _part_whole_defects(
1267
- cursor, schema_name, table_name, table_profile["columns"], formulas, shares
 
1268
  )
1269
  )
1270
  remaining.extend(
 
77
  default_aggregation,
78
  is_flag_column,
79
  is_intensive_measure,
80
+ is_open_ratio_name,
81
  is_rate_column,
82
  )
83
 
 
92
  from demoprep_app.quality.findings import Finding
93
  from demoprep_app.dataset.formula import (
94
  FormulaEvaluationError,
95
+ alias_target as formula_alias_target,
96
  identifiers as formula_identifiers,
97
  to_sql as formula_to_sql,
98
  )
 
224
  return shares
225
 
226
 
227
+ def measure_formats(bundle: Any) -> dict[str, dict[str, Any]]:
228
+ """{"TABLE.COLUMN": {"fmt": ..., "signed": ..., "kind": ...}} for every
229
+ measure column of a DatasetBundle β€” the blueprint's own declarations, as
230
+ the engine stamped them on the columns.
231
+
232
+ The sibling of measure_formulas / measure_shares. The post-load gate feeds
233
+ `fmt` and `signed` to infer_bound so a loaded SESSIONS (int) or COGS
234
+ (currency) is classified non-negative exactly as the blueprint classified
235
+ it, instead of coming back "no bound class matched".
236
+ """
237
+ formats: dict[str, dict[str, Any]] = {}
238
+ for table in getattr(bundle, "tables", []) or []:
239
+ for column in table.columns:
240
+ fmt = getattr(column, "fmt", "") or ""
241
+ kind = getattr(column, "measure_kind", "") or ""
242
+ if not fmt and not kind:
243
+ continue # a key, a date, an attribute
244
+ formats[f"{table.name}.{column.name}"] = {
245
+ "fmt": fmt,
246
+ "signed": bool(getattr(column, "signed", False)),
247
+ "kind": kind,
248
+ }
249
+ return formats
250
+
251
+
252
  def apply_measure_omissions(bundle: Any, omissions: Iterable[Mapping[str, str]]) -> list[str]:
253
  """Remove omitted measure columns from an in-memory DatasetBundle.
254
 
 
383
  for idx, (col, other, sign) in enumerate(candidates):
384
  differing = int(row[idx] or 0)
385
  if differing == 0:
386
+ formula = formulas.get(f"{table_name}.{col}", "")
387
+ detail, suggestion = _mirror_explanation(table_name, col, other, sign, formula)
388
  defects.append({
389
  "table": table_name,
390
  "column": col,
391
  "defect": "mirrors_other_column",
392
+ "detail": detail,
393
+ "suggestion": suggestion,
 
 
 
 
 
 
 
394
  })
395
  return defects
396
 
397
 
398
+ def _mirror_explanation(table_name: str, col: str, other: str, sign: int, formula: str) -> tuple[str, str]:
399
+ """(detail, suggestion) for a mirrored column, by CAUSE.
400
+
401
+ Two different mistakes produce a mirror, and the old text ("references an
402
+ identifier that is not a measure of this table") described only the second
403
+ β€” and was wrong for Humpty Dump's DAYS_ON_SITE = RENTAL_DAYS * 1, whose
404
+ identifier existed:
405
+
406
+ identity the formula IS the other column (X, X * 1, 1 * X, X / 1,
407
+ X + 0): nothing to recompute, the measure is a duplicate
408
+ hollow the formula's OTHER input(s) contributed nothing β€” all-zero,
409
+ or an identifier the engine resolved to nothing β€” so
410
+ ACTUAL - FORECAST stored -FORECAST
411
+ """
412
+ signed_other = f"{'-' if sign < 0 else ''}{other}"
413
+ if formula_alias_target(formula) == other:
414
+ return (
415
+ f"every row equals {other} β€” the formula '{formula}' is an identity of {other}",
416
+ f"{col} = '{formula}' is a copy of {other}, so it is a duplicate tile, not a "
417
+ f"measure: give {col} a formula that computes something different from {other} in "
418
+ f"the blueprint, or drop {col}",
419
+ )
420
+ others = sorted(formula_identifiers(formula) - {other})
421
+ inputs = ", ".join(others) if others else "its other input"
422
+ return (
423
+ f"every row equals {signed_other} β€” the formula's other input(s) ({inputs}) contributed "
424
+ "nothing (all-zero, or not a measure of this table)",
425
+ f"{col} = '{formula}': {inputs} must exist in {table_name} and vary β€” fix those inputs "
426
+ f"in the blueprint so {col} computes something other than a copy of {other}, or drop {col}",
427
+ )
428
+
429
+
430
  def _part_whole_defects(
431
  cursor: Any,
432
  schema_name: str,
 
434
  stats: dict[str, dict],
435
  formulas: Mapping[str, str],
436
  shares: Mapping[str, str],
437
+ formats: Mapping[str, Mapping[str, Any]] | None = None,
438
  ) -> list[dict]:
439
  """part > whole for every PART / WHOLE pair a ratio formula implies.
440
 
 
457
  tbl, _, ratio_col = key.partition(".")
458
  if tbl != table_name:
459
  continue
460
+ declared = (formats or {}).get(key, {})
461
+ ratio_bound = semantic_bound(
462
+ ratio_col, stats.get(ratio_col, {}), formula,
463
+ fmt=str(declared.get("fmt") or ""), signed=bool(declared.get("signed")),
464
+ )
465
  for part, whole in part_whole_pairs(formula, ratio_bound, shares=table_shares):
466
  if part in stats and whole in stats and (part, whole, ratio_col) not in pairs:
467
  pairs.append((part, whole, ratio_col))
 
494
  return defects
495
 
496
 
497
+ def semantic_bound(
498
+ column_name: str,
499
+ stats: dict,
500
+ formula: str = "",
501
+ *,
502
+ fmt: str = "",
503
+ signed: bool = False,
504
+ ) -> MeasureBound | None:
505
+ """The legal range for a loaded column: its name, its declared format and
506
+ its observed profile.
507
+
508
+ Post-load the observed AVG stands in for the declared level: it is what
509
+ tells a 0-100 percentage from a 0-1 rate, and which standard scale a score
510
+ sits on. `fmt` and `signed` are the blueprint's own declarations, carried
511
+ on the DatasetBundle's columns (measure_formats). They used to be withheld
512
+ here on the theory that format-derived non-negativity "would flag every
513
+ legitimate negative margin column" β€” but infer_bound answers the signed
514
+ classes (the name, the declared flag, a difference formula) BEFORE it reads
515
+ the format, so withholding it only meant every integer count and every
516
+ money column came back None and was posted as a vocabulary gap (~600 of
517
+ 662 Slack warnings, 2026-10-12..15). A caller with no bundle still gets
518
+ the name-only answer.
519
  """
520
+ return infer_bound(
521
+ column_name, fmt or "", {"mean": stats.get("avg")}, signed=signed, formula=formula
522
+ )
523
 
524
 
525
  def _column_defects(
526
  table_name: str,
527
  stats: dict[str, dict],
528
  formulas: Mapping[str, str] | None = None,
529
+ formats: Mapping[str, Mapping[str, Any]] | None = None,
530
  ) -> tuple[list[dict], list[str], list[Finding]]:
531
  """Per-column defects, warnings and structured findings from the profile.
532
 
 
536
  strings are their rendering.
537
  """
538
  formulas = formulas or {}
539
+ formats = formats or {}
540
  defects: list[dict] = []
541
  warnings: list[str] = []
542
  findings: list[Finding] = []
 
571
  f"generate {col} as 0/1 only, or drop the _FLAG suffix if it is really a count"
572
  ),
573
  })
574
+ declared = formats.get(f"{table_name}.{col}", {})
575
+ semantic = semantic_bound(
576
+ col, s, formulas.get(f"{table_name}.{col}", ""),
577
+ fmt=str(declared.get("fmt") or ""), signed=bool(declared.get("signed")),
578
+ )
579
  if semantic is None:
580
  # No bound class matched this name, so NOTHING could be range-checked.
581
  # That is the "our inference missed a vocabulary" signal; it is not a
582
+ # defect in the data, and it is not silently ignored either. Severity
583
+ # `info` (boone 2026-10-20): recorded, rolled up in Slack, never a
584
+ # warning per measure.
585
  findings.append(Finding(
586
  code="bound_not_inferable",
587
+ severity="info",
588
  stage="post_load",
589
  message=f"no bound class matched {col}, so no range was checked for it",
590
  suggestion=suggestion_for_unclassified(col),
 
598
  )
599
  findings.append(Finding(
600
  code="bound_not_inferable",
601
+ severity="info",
602
  stage="post_load",
603
  message=f"{semantic.gap} β€” no bound could be checked for this column",
604
  suggestion=semantic.suggestion or semantic.suggest_fix(col),
 
753
  ))
754
  return statements
755
 
756
+ if defect["defect"] == "mirrors_other_column":
757
+ # Never "repair" a mirror by recomputing it: the column IS its formula
758
+ # over these inputs, so the recompute reproduces the copy, the re-profile
759
+ # still mirrors, and the run posts repair_failed on top of the mirror
760
+ # (Humpty Dump, 2026-10-13: three builds in 70 minutes on one measure).
761
+ # A mirror is a blueprint defect; _OMIT_WORTHY drops the column.
762
+ return []
763
+ # all_zero / constant: only a formula can fix these.
764
  return recompute()
765
 
766
 
 
851
  for table_name, column_name, data_type in rows:
852
  dt = data_type or ""
853
  rng = bounded_range_for_column(column_name)
854
+ # An open ratio (coverage, combined, turnover) keeps the _RATIO
855
+ # aggregation but has no ceiling β€” never report it "outside [0, 1]".
856
+ if rng and is_measure_column(column_name, dt) and not is_open_ratio_name(column_name):
857
  bounded.setdefault(table_name, []).append((column_name, rng[0], rng[1]))
858
  if is_month_bucket_column(column_name, dt):
859
  month_cols.setdefault(table_name, []).append(column_name)
 
1144
  shares: Optional[Mapping[str, str]] = None,
1145
  required_measures: Optional[Iterable[str]] = None,
1146
  fact_tables: Optional[Iterable[str]] = None,
1147
+ formats: Optional[Mapping[str, Mapping[str, Any]]] = None,
1148
  repair: bool = True,
1149
  drop_unrepairable: bool = True,
1150
  ) -> dict:
 
1157
  measure_shares(bundle). The structural half of the part <= whole
1158
  check: a declared part is verified against its whole, and a declared
1159
  part in a ratio's denominator is never read as a part of its numerator.
1160
+ formats: {"TABLE.COLUMN": {"fmt", "signed", "kind"}} β€” see
1161
+ measure_formats(bundle). The blueprint's declared format and sign for
1162
+ each measure, so the range check classifies a count or an amount the
1163
+ way the blueprint did instead of reporting it as unclassified.
1164
  required_measures: measure names (bare or "TABLE.COLUMN") the brief's core
1165
  claim depends on β€” the planted insights' measures, the headline of the
1166
  story. The gate hard-fails when ANY of them ends up unusable, or when
 
1197
 
1198
  formulas = dict(formulas or {})
1199
  shares = dict(shares or {})
1200
+ formats = dict(formats or {})
1201
  required = {str(m).upper() for m in (required_measures or ())}
1202
 
1203
  cursor = connection.cursor()
 
1265
  "avg": stats["avg"],
1266
  }
1267
  column_defects, column_warnings, column_findings = _column_defects(
1268
+ table_name, table_profile["columns"], formulas, formats
1269
  )
1270
  profile["defects"].extend(column_defects)
1271
  profile["warnings"].extend(column_warnings)
1272
  profile["findings"].extend(f.to_dict() for f in column_findings)
1273
  profile["defects"].extend(
1274
  _part_whole_defects(
1275
+ cursor, schema_name, table_name, table_profile["columns"], formulas, shares,
1276
+ formats,
1277
  )
1278
  )
1279
  profile["defects"].extend(
 
1359
  "max": stats["max"],
1360
  "avg": stats["avg"],
1361
  }
1362
+ column_defects, _, _ = _column_defects(
1363
+ table_name, table_profile["columns"], formulas, formats
1364
+ )
1365
  remaining.extend(column_defects)
1366
  remaining.extend(
1367
  _part_whole_defects(
1368
+ cursor, schema_name, table_name, table_profile["columns"], formulas, shares,
1369
+ formats,
1370
  )
1371
  )
1372
  remaining.extend(
demoprep_app/pipeline/build_demo.py CHANGED
@@ -29,11 +29,11 @@ import hashlib
29
  from dataclasses import dataclass, field
30
 
31
  from demoprep_app.dataset.contracts import DatasetBundle
32
- from demoprep_app.dataset.engine import BlueprintEngine
33
- from demoprep_app.dataset.formula import FormulaEvaluationError
34
  from demoprep_app.dataset.validator import ValidationReport, validate_bundle
35
  from demoprep_app.ddl import DatasetDdlCompiler
36
- from demoprep_app.quality.findings import Finding, blocking, send_blocking
37
  from demoprep_app.scenario.blueprint import DemoBlueprint
38
  from demoprep_app.scenario.blueprint_author import LLMResponseTruncated, author_blueprint
39
  from demoprep_app.scenario.contract import ScenarioContract
@@ -43,6 +43,15 @@ from demoprep_app.scenario.contract import ScenarioContract
43
  MAX_TUNING_ROUNDS = 3
44
  # When an insight is too weak, multiply its distance-from-1.0 by this.
45
  BOOST_FACTOR = 1.6
 
 
 
 
 
 
 
 
 
46
  # How many times we repair a formula the engine refused to compute and try
47
  # again. Formula validation at blueprint time should make this dead code; it
48
  # exists so an unforeseen formula failure costs one measure, not the demo.
@@ -182,6 +191,18 @@ def build_demo(
182
  + (", ".join(directives.prohibited_terms) or "none")
183
  )
184
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  # ------------------------------------------------------------------
186
  # 2. Generate + validate loop (deterministic, in-memory, cheap)
187
  # ------------------------------------------------------------------
@@ -198,6 +219,7 @@ def build_demo(
198
  # into the demo, so that goes straight to `findings` and accumulates.
199
  round_findings: list[Finding] = []
200
  round_warnings: list[str] = []
 
201
  for round_num in range(1, MAX_TUNING_ROUNDS + 1):
202
  progress(f"Generating dataset (round {round_num})...")
203
  round_findings, round_warnings = [], []
@@ -251,16 +273,41 @@ def build_demo(
251
  break
252
  if report.range_errors and not report.failed_insights():
253
  break # deterministic: another identical round cannot fix a range
254
- _boost_weak_insights(blueprint, report)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
  findings.extend(round_findings)
257
  warnings.extend(round_warnings)
258
  if report is not None and report.failed_insights():
259
  warnings.append(
260
- "Some planted insights are weaker than promised after tuning; "
261
- "the demo will work but those charts may be subtle:\n" + report.summary()
 
 
 
262
  )
263
- findings.extend(f for f in report.findings if f.code == "insight_weak")
264
 
265
  # ------------------------------------------------------------------
266
  # 3. Derive DDL from the generated dataset (unchanged contract)
@@ -308,22 +355,16 @@ def _record(
308
 
309
 
310
  def _dedupe(findings: list[Finding]) -> list[Finding]:
311
- """Identical records = one finding.
312
-
313
- The message is part of the key on purpose: two different statements about
314
- one measure at one stage can be two defects (two bounds, two facts). The
315
- tuning loop's re-measurements of one defect are not collapsed here β€” the
316
- loop keeps only the round that ships (see build_demo, step 2).
 
 
317
  """
318
- seen: set[tuple] = set()
319
- unique: list[Finding] = []
320
- for finding in findings:
321
- key = (finding.code, finding.stage, finding.table, finding.measure, finding.message)
322
- if key in seen:
323
- continue
324
- seen.add(key)
325
- unique.append(finding)
326
- return unique
327
 
328
 
329
  def _dedupe_strings(lines: list[str]) -> list[str]:
@@ -384,14 +425,105 @@ def _generate_with_formula_repair(
384
  )
385
 
386
 
387
- def _boost_weak_insights(blueprint: DemoBlueprint, report: ValidationReport) -> None:
388
- """Contract-level cleanup: strengthen only what failed, deterministically."""
389
- failed = {c.insight_id for c in report.failed_insights()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  for insight in blueprint.insights:
391
- if insight.id not in failed:
 
392
  continue
393
- distance = insight.magnitude - 1.0
394
- insight.magnitude = round(1.0 + distance * BOOST_FACTOR, 3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  # Thin segments can't show an effect no matter the magnitude β€”
396
  # give the target value more mix share as well.
397
  for dim in blueprint.dimensions:
@@ -400,6 +532,7 @@ def _boost_weak_insights(blueprint: DemoBlueprint, report: ValidationReport) ->
400
  for value in dim.values:
401
  if value.name == insight.dimension_value:
402
  value.mix_weight = min(value.mix_weight * 1.5, 5.0)
 
403
 
404
 
405
  # ---------------------------------------------------------------------------
 
29
  from dataclasses import dataclass, field
30
 
31
  from demoprep_app.dataset.contracts import DatasetBundle
32
+ from demoprep_app.dataset.engine import POSITION_SWING, BlueprintEngine
33
+ from demoprep_app.dataset.formula import FormulaEvaluationError, ratio_pairs
34
  from demoprep_app.dataset.validator import ValidationReport, validate_bundle
35
  from demoprep_app.ddl import DatasetDdlCompiler
36
+ from demoprep_app.quality.findings import Finding, blocking, dedupe, send_blocking
37
  from demoprep_app.scenario.blueprint import DemoBlueprint
38
  from demoprep_app.scenario.blueprint_author import LLMResponseTruncated, author_blueprint
39
  from demoprep_app.scenario.contract import ScenarioContract
 
43
  MAX_TUNING_ROUNDS = 3
44
  # When an insight is too weak, multiply its distance-from-1.0 by this.
45
  BOOST_FACTOR = 1.6
46
+ # A BOUNDED measure (a rate, a score, a share) is pushed in its band instead:
47
+ # PlantedInsight.drive_boost grows by BOOST_FACTOR per round up to this.
48
+ MAX_DRIVE_BOOST = 4.0
49
+ # The share of a band's theoretical delivery (position 0.5 -> 0.5 +/-
50
+ # POSITION_SWING) a promise may claim; grouped means never reach the asymptote.
51
+ BAND_DELIVERY_MARGIN = 0.9
52
+ # Two rounds whose weak insights realized ratios this close are the same
53
+ # round: the boost had no effect, so stop (the rounds are deterministic).
54
+ STALL_TOLERANCE = 0.005
55
  # How many times we repair a formula the engine refused to compute and try
56
  # again. Formula validation at blueprint time should make this dead code; it
57
  # exists so an unforeseen formula failure costs one measure, not the demo.
 
191
  + (", ".join(directives.prohibited_terms) or "none")
192
  )
193
 
194
+ # A planted insight needs enough rows on each side to be measured. Size
195
+ # its segment ONCE, from the row count the engine will actually write,
196
+ # instead of discovering "too few rows" after a full generation and
197
+ # bumping mix_weight a round at a time (findings triage 2026-10-15, B3).
198
+ for fix in blueprint.ensure_insight_support(BlueprintEngine.rows_per_fact(blueprint, row_count_guidance)):
199
+ progress(f"insight support: {fix}")
200
+ warnings.append(f"insight support: {fix}")
201
+ findings.append(Finding(
202
+ code="insight_support_raised", severity="info", stage="blueprint",
203
+ message=fix, repair="repaired",
204
+ ))
205
+
206
  # ------------------------------------------------------------------
207
  # 2. Generate + validate loop (deterministic, in-memory, cheap)
208
  # ------------------------------------------------------------------
 
219
  # into the demo, so that goes straight to `findings` and accumulates.
220
  round_findings: list[Finding] = []
221
  round_warnings: list[str] = []
222
+ previous_observed: dict[str, float] = {}
223
  for round_num in range(1, MAX_TUNING_ROUNDS + 1):
224
  progress(f"Generating dataset (round {round_num})...")
225
  round_findings, round_warnings = [], []
 
273
  break
274
  if report.range_errors and not report.failed_insights():
275
  break # deterministic: another identical round cannot fix a range
276
+ observed_now = {
277
+ c.insight_id: c.observed_ratio for c in report.failed_insights() if c.measurable
278
+ }
279
+ if _tuning_stalled(previous_observed, observed_now):
280
+ progress(
281
+ "tuning stalled: the weak insights realized the same ratios as last round, so "
282
+ "another boost would not move them (the rounds are deterministic)"
283
+ )
284
+ break
285
+ previous_observed = observed_now
286
+ for note in _boost_weak_insights(blueprint, report):
287
+ progress(f"insight promise capped: {note}")
288
+ warnings.append(f"insight promise capped: {note}")
289
+ findings.append(Finding(
290
+ code="insight_promise_capped",
291
+ severity="warning",
292
+ stage="generation",
293
+ message=note,
294
+ suggestion=(
295
+ "if the original lift is the point of the story, widen the measure's declared "
296
+ "band (params.low/high) or plant it on a measure that is not bounded; "
297
+ "otherwise the capped promise is what the chart can honestly show"
298
+ ),
299
+ ))
300
 
301
  findings.extend(round_findings)
302
  warnings.extend(round_warnings)
303
  if report is not None and report.failed_insights():
304
  warnings.append(
305
+ "Some planted insights are weaker than promised after tuning (or could not be "
306
+ "measured); the demo will work but those charts may be subtle:\n" + report.summary()
307
+ )
308
+ findings.extend(
309
+ f for f in report.findings if f.code in ("insight_weak", "insight_unmeasurable")
310
  )
 
311
 
312
  # ------------------------------------------------------------------
313
  # 3. Derive DDL from the generated dataset (unchanged contract)
 
355
 
356
 
357
  def _dedupe(findings: list[Finding]) -> list[Finding]:
358
+ """One finding per (code, table, measure) β€” quality.findings.dedupe.
359
+
360
+ The same rule chat_interface applies when it merges these with the
361
+ post-load gate's records, so a measure flagged at two stages is one
362
+ finding wherever the list is read. A record with no measure keeps its
363
+ message in the key (two weak insights are two facts). The tuning loop's
364
+ re-measurements of one defect are not collapsed here β€” the loop keeps only
365
+ the round that ships (see build_demo, step 2).
366
  """
367
+ return dedupe(findings)
 
 
 
 
 
 
 
 
368
 
369
 
370
  def _dedupe_strings(lines: list[str]) -> list[str]:
 
425
  )
426
 
427
 
428
+ def _tuning_stalled(previous: dict[str, float], current: dict[str, float]) -> bool:
429
+ """True when every weak insight both rounds measured realized the same
430
+ ratio β€” the last boost changed nothing, so the next one will not either."""
431
+ common = set(previous) & set(current)
432
+ if not current or not common:
433
+ return False
434
+ return all(abs(current[k] - previous[k]) < STALL_TOLERANCE for k in common)
435
+
436
+
437
+ def _band_of(measure, by_name: dict) -> tuple[float, float] | None:
438
+ """The [low, high] band a measure's rows are placed in, when it has one:
439
+ a bounded base measure's sampling band, a share_of's fraction range, or
440
+ the fraction range behind a PART / WHOLE ratio whose part is a share of
441
+ the whole (the ratio IS the fraction)."""
442
+ if measure.kind == "base":
443
+ if measure.share_of:
444
+ return float(measure.params.get("low", 0.05)), float(measure.params.get("high", 0.85))
445
+ band = getattr(measure, "band", None)
446
+ return (float(band[0]), float(band[1])) if band else None
447
+ pairs = ratio_pairs(measure.formula or "")
448
+ if len(pairs) == 1:
449
+ part = by_name.get(pairs[0][0])
450
+ if part is not None and part.kind == "base" and part.share_of == pairs[0][1]:
451
+ return float(part.params.get("low", 0.05)), float(part.params.get("high", 0.85))
452
+ return None
453
+
454
+
455
+ def _deliverable_band_ratio(blueprint: DemoBlueprint, insight) -> tuple[float, float] | None:
456
+ """(floor, cap): the target/baseline ratios a BOUNDED measure can deliver.
457
+
458
+ The engine places a bounded measure's rows at a POSITION in its band:
459
+ the baseline sits near the middle, the strongest drive reaches
460
+ 0.5 + POSITION_SWING (engine._position_in_band). So the biggest lift any
461
+ promise can produce is (low + 0.9 x width) / (low + 0.5 x width) β€” on a
462
+ band of 0.60..0.95 that is 1.35x, whatever the magnitude says. Promising
463
+ 1.6x there and boosting it to 3.7x moved only the pass bar (Spendflo,
464
+ Verizon; findings triage 2026-10-15, B2). None for an unbounded measure.
465
+ """
466
+ fact = blueprint.fact_for_measure(insight.measure)
467
+ if fact is None:
468
+ return None
469
+ by_name = {m.name: m for m in fact.measures}
470
+ measure = by_name.get(insight.measure)
471
+ band = _band_of(measure, by_name) if measure is not None else None
472
+ if band is None:
473
+ return None
474
+ low, high = band
475
+ width = high - low
476
+ centre = low + 0.5 * width
477
+ if width <= 0 or centre <= 0:
478
+ return None
479
+ top = low + (0.5 + POSITION_SWING) * width
480
+ bottom = low + (0.5 - POSITION_SWING) * width
481
+ cap = 1.0 + (top / centre - 1.0) * BAND_DELIVERY_MARGIN
482
+ floor = 1.0 - (1.0 - bottom / centre) * BAND_DELIVERY_MARGIN
483
+ return round(floor, 4), round(cap, 4)
484
+
485
+
486
+ def _boost_weak_insights(blueprint: DemoBlueprint, report: ValidationReport) -> list[str]:
487
+ """Contract-level cleanup: strengthen only what failed, deterministically.
488
+
489
+ An insight on an UNBOUNDED measure gets a bigger magnitude. One on a
490
+ bounded measure (a rate, a score, a share) gets its promise CAPPED at what
491
+ the band can deliver and its drive_boost raised instead β€” more drive moves
492
+ the rows' position in the band, while a bigger magnitude only raised the
493
+ pass bar. Returns one note per capped promise, for the run's findings.
494
+ """
495
+ notes: list[str] = []
496
+ failed = {c.insight_id: c for c in report.failed_insights()}
497
  for insight in blueprint.insights:
498
+ check = failed.get(insight.id)
499
+ if check is None:
500
  continue
501
+ if getattr(check, "measurable", True):
502
+ if insight.authored_magnitude is None:
503
+ # Keep the promise as the blueprint made it: the validator reports
504
+ # THAT number, not the boosted one (findings triage 2026-10-15, B1).
505
+ insight.authored_magnitude = insight.magnitude
506
+ deliverable = _deliverable_band_ratio(blueprint, insight)
507
+ if deliverable is None:
508
+ distance = insight.magnitude - 1.0
509
+ insight.magnitude = round(1.0 + distance * BOOST_FACTOR, 3)
510
+ else:
511
+ floor, cap = deliverable
512
+ laggard = insight.magnitude < 1.0
513
+ limit = floor if laggard else cap
514
+ out_of_reach = insight.magnitude < limit if laggard else insight.magnitude > limit
515
+ if out_of_reach:
516
+ old = insight.magnitude
517
+ insight.magnitude = round(limit, 3)
518
+ notes.append(
519
+ f"insight {insight.id} promised {old:g}x on {insight.measure}, whose band can "
520
+ f"deliver at most {limit:.2f}x β€” promise capped at {limit:.2f}x and the rows "
521
+ "pushed further in the band instead"
522
+ )
523
+ insight.drive_boost = round(min(insight.drive_boost * BOOST_FACTOR, MAX_DRIVE_BOOST), 3)
524
+ # An UNMEASURABLE insight (too few rows on a side) is not a magnitude
525
+ # problem; boosting it only inflates the promise the report prints.
526
+ # The mix_weight bump below is the part that can help it.
527
  # Thin segments can't show an effect no matter the magnitude β€”
528
  # give the target value more mix share as well.
529
  for dim in blueprint.dimensions:
 
532
  for value in dim.values:
533
  if value.name == insight.dimension_value:
534
  value.mix_weight = min(value.mix_weight * 1.5, 5.0)
535
+ return notes
536
 
537
 
538
  # ---------------------------------------------------------------------------
demoprep_app/quality/findings.py CHANGED
@@ -246,6 +246,54 @@ def send_blocking(findings: Iterable[Finding]) -> list[Finding]:
246
  return [f for f in findings if f.blocks_send]
247
 
248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  def render(findings: Iterable[Finding]) -> list[str]:
250
  return [f.render_line() for f in findings]
251
 
 
246
  return [f for f in findings if f.blocks_send]
247
 
248
 
249
+ #: Which record survives when two stages flag the same (code, table, measure).
250
+ #: Higher wins: the post-load record carries the observed Snowflake stats, a
251
+ #: blueprint-stage record only the declared mean.
252
+ _STAGE_PRECEDENCE = {
253
+ "blueprint": 0, "generation": 1, "validation": 2, "liveboard": 3, "post_load": 4, "send": 5,
254
+ }
255
+
256
+
257
+ def dedupe_key(finding: Finding) -> tuple:
258
+ """One measure, one code, one finding β€” whichever stage noticed it.
259
+
260
+ CSAT_SCORE_SUM was posted twice per build (2026-10-13): once by the
261
+ blueprint stage from its declared mean and once by the post-load gate from
262
+ its observed mean, with different text, so a message-keyed dedupe kept
263
+ both. A record that is not measure-scoped (an insight, a formula repair, a
264
+ dimension) keeps its message in the key: two of those under one code are
265
+ two different facts.
266
+ """
267
+ if finding.measure:
268
+ return (finding.code, finding.table.upper(), finding.measure.upper())
269
+ return (finding.code, finding.stage, finding.table.upper(), finding.message)
270
+
271
+
272
+ def dedupe(findings: Iterable[Finding]) -> list[Finding]:
273
+ """Collapse records that describe the same thing (see dedupe_key).
274
+
275
+ Across STAGES the later one wins: of everything said about one
276
+ (code, table, measure), only the records from the highest-precedence
277
+ stage survive β€” post-load over blueprint, because it carries the observed
278
+ stats. Within that stage, two different messages are two facts (piled on
279
+ the high bound AND on the low bound) and both are kept; an identical
280
+ repeat is dropped. Order of first appearance is preserved.
281
+ """
282
+ groups: dict[tuple, list[Finding]] = {}
283
+ for finding in findings:
284
+ groups.setdefault(dedupe_key(finding), []).append(finding)
285
+ out: list[Finding] = []
286
+ for members in groups.values():
287
+ top = max(_STAGE_PRECEDENCE.get(m.stage, -1) for m in members)
288
+ seen_messages: set[str] = set()
289
+ for member in members:
290
+ if _STAGE_PRECEDENCE.get(member.stage, -1) != top or member.message in seen_messages:
291
+ continue
292
+ seen_messages.add(member.message)
293
+ out.append(member)
294
+ return out
295
+
296
+
297
  def render(findings: Iterable[Finding]) -> list[str]:
298
  return [f.render_line() for f in findings]
299
 
demoprep_app/scenario/blueprint.py CHANGED
@@ -24,6 +24,7 @@ import ast
24
  import math
25
 
26
  from dataclasses import dataclass, field
 
27
  from typing import Any
28
 
29
  from demoprep_app.dataset.formula import (
@@ -39,9 +40,11 @@ from demoprep_app.dataset.measure_bounds import (
39
  MeasureBound,
40
  infer_bound,
41
  is_scale_bound,
 
42
  part_whole_pairs,
43
  sampling_band,
44
  )
 
45
  from demoprep_app.quality.findings import Finding
46
 
47
  VALID_INSIGHT_TYPES = ("segment_outlier", "segment_laggard", "trend_break", "seasonal_spike")
@@ -217,6 +220,35 @@ class PlantedInsight:
217
  window_end: str = ""
218
  spotter_question: str = "" # the question an SE types live
219
  expected_finding: str = "" # what the chart should show
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
 
222
  @dataclass(slots=True)
@@ -228,6 +260,29 @@ class DateSpec:
228
  include_date_dimension: bool = False
229
  fiscal_or_custom_calendar: str = "" # non-empty forces include_date_dimension
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
  @dataclass(slots=True)
233
  class DemoBlueprint:
@@ -262,10 +317,13 @@ class DemoBlueprint:
262
  `[BOUND-WARN]` hardening lines, so each record carries the measure, the
263
  table and the concrete declaration to add β€” a log line cannot.
264
 
265
- These are WARNINGS by directive: an uninferable range means our name
266
- inference missed a vocabulary, which is a signal to report, not a reason
267
- to kill a build. They do NOT reach the structural-abort path (see
268
- `problems()`, which never contains bound text).
 
 
 
269
  """
270
  from demoprep_app.dataset.measure_bounds import infer_bound
271
  from demoprep_app.quality.findings import Finding
@@ -280,7 +338,7 @@ class DemoBlueprint:
280
  continue
281
  findings.append(Finding(
282
  code="bound_not_inferable",
283
- severity="warning",
284
  stage="blueprint",
285
  message=bound.gap,
286
  suggestion=bound.suggestion or bound.suggest_fix(m.name),
@@ -489,35 +547,105 @@ class DemoBlueprint:
489
  fixes.append(f"{m.name}: flag measure -> fmt 'int'")
490
  m.fmt = "int"
491
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  # --- 2. infer share_of from PART / WHOLE formulas ---
493
  # Which `A / B` is a part over its whole is measure_bounds'
494
  # answer, the same one the post-load gate checks against.
495
  shares = {m.name: m.share_of for m in fact.measures if m.share_of}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  for m in fact.measures:
497
  if m.kind != "derived" or not m.formula:
498
  continue
499
  ratio_bound = infer_bound(m.name, m.fmt, m.params, signed=m.signed, formula=m.formula)
500
- for part_name, whole_name in part_whole_pairs(m.formula, ratio_bound, shares=shares):
501
- part = by_name.get(part_name)
502
- whole = by_name.get(whole_name)
 
 
503
  if part is None or whole is None or part is whole:
504
- continue # cross-fact / unknown: a formula problem, not a bound
505
- if part.kind != "base" or part.share_of:
506
- continue # already bounded, or not something we sample
507
- if part.name.upper().endswith("_FLAG"):
508
- continue # a flag is already 0/1
509
- if _share_of_cycle(part.name, whole, by_name):
510
  continue
511
- part.share_of = whole.name
512
- shares[part.name] = whole.name
513
- low, high = _inferred_fraction_range(part, whole)
514
- part.params["low"], part.params["high"] = low, high
515
- if part.per:
516
- fixes.append(f"{part.name}: dropped per '{part.per}' (now a share of {whole.name})")
517
- part.per = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  fixes.append(
519
- f"{part.name}: inferred share_of {whole.name} from {m.name} = '{m.formula}' "
520
- f"(fraction {low:.2f}-{high:.2f}) so {m.name} stays <= 1"
521
  )
522
 
523
  fixes.extend(self._differentiate_dimension_performance())
@@ -801,6 +929,69 @@ class DemoBlueprint:
801
  self.metadata.setdefault("autorepairs", []).extend(fixes)
802
  return fixes
803
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
804
  def findings(self) -> list[Finding]:
805
  """Validation report as records: every rule decides its OWN severity.
806
 
@@ -963,6 +1154,26 @@ class DemoBlueprint:
963
  critical("measure_kind_invalid", f"Measure {m.name}: kind must be 'base' or 'derived'.",
964
  suggestion="autorepair infers the kind from the formula",
965
  table=fact.name, measure=m.name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
966
  if m.fmt not in VALID_FORMATS:
967
  critical("measure_fmt_invalid", f"Measure {m.name}: fmt must be one of {VALID_FORMATS}.",
968
  suggestion="autorepair coerces it to float", table=fact.name, measure=m.name)
@@ -985,6 +1196,8 @@ class DemoBlueprint:
985
  if len(self.insights) < 3:
986
  warn("insights_too_few", "Provide at least 3 planted insights β€” these are the demo story.",
987
  suggestion="autorepair synthesizes segment_outlier insights from the base measures")
 
 
988
  for i in self.insights:
989
  if i.insight_type not in VALID_INSIGHT_TYPES:
990
  critical("insight_type_invalid",
@@ -1012,10 +1225,52 @@ class DemoBlueprint:
1012
  f"Insight {i.id}: dimension_value '{i.dimension_value}' is not a value of {i.dimension}.",
1013
  suggestion="autorepair remaps it to an existing value",
1014
  table=i.dimension, measure=i.measure)
1015
- if i.insight_type in ("trend_break", "seasonal_spike") and not i.window_start:
1016
- warn("insight_window_missing",
1017
- f"Insight {i.id}: {i.insight_type} requires window_start (ISO date).",
1018
- suggestion="autorepair adds a default window_start", measure=i.measure)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1019
  if not (i.magnitude >= 1.15 or i.magnitude <= 0.85):
1020
  warn("insight_too_subtle",
1021
  f"Insight {i.id}: magnitude {i.magnitude} is too subtle to demo. Use >=1.15 or <=0.85.",
@@ -1133,6 +1388,9 @@ class DemoBlueprint:
1133
  # --- insights ---
1134
  all_measure_names = {m.name for f in self.facts for m in f.measures}
1135
  base_measures = [m.name for f in self.facts for m in f.measures if m.kind == "base"]
 
 
 
1136
  kept: list[PlantedInsight] = []
1137
  for i in self.insights:
1138
  if i.insight_type not in VALID_INSIGHT_TYPES:
@@ -1165,9 +1423,35 @@ class DemoBlueprint:
1165
  else:
1166
  fixes.append(f"insight {i.id}: dropped (dimension has no values)")
1167
  continue
1168
- if i.insight_type in ("trend_break", "seasonal_spike") and not i.window_start:
1169
- i.window_start = "2025-06-01"
1170
- fixes.append(f"insight {i.id}: added window_start")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1171
  if not (i.magnitude >= 1.15 or i.magnitude <= 0.85):
1172
  i.magnitude = 1.3
1173
  fixes.append(f"insight {i.id}: magnitude -> 1.3")
@@ -1270,15 +1554,55 @@ def _ratio_is_structurally_bounded(measure, bound: MeasureBound, by_name: dict)
1270
  if bound.kind != "rate" or abs(bound.scale - 1.0) > 1e-9:
1271
  return False
1272
  pairs = formula_ratio_pairs(measure.formula or "")
1273
- if not pairs:
 
1274
  return False
1275
  for part_name, whole_name in pairs:
1276
  part = by_name.get(part_name)
1277
- if part is None or part.share_of != whole_name:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1278
  return False
1279
  return True
1280
 
1281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1282
  def _wrap_formula_in_bound(formula: str, bound: MeasureBound) -> str:
1283
  """`f` -> `MIN(MAX(f, low), high)` unless it is already wrapped that way.
1284
 
 
24
  import math
25
 
26
  from dataclasses import dataclass, field
27
+ from datetime import date, timedelta
28
  from typing import Any
29
 
30
  from demoprep_app.dataset.formula import (
 
40
  MeasureBound,
41
  infer_bound,
42
  is_scale_bound,
43
+ named_score_scale,
44
  part_whole_pairs,
45
  sampling_band,
46
  )
47
+ from demoprep_app.dataset.measure_names import is_open_ratio_name, is_score_column
48
  from demoprep_app.quality.findings import Finding
49
 
50
  VALID_INSIGHT_TYPES = ("segment_outlier", "segment_laggard", "trend_break", "seasonal_spike")
 
220
  window_end: str = ""
221
  spotter_question: str = "" # the question an SE types live
222
  expected_finding: str = "" # what the chart should show
223
+ # The magnitude as the blueprint stated it, recorded the first time the
224
+ # tuning loop boosts `magnitude` (build_demo._boost_weak_insights). The
225
+ # validator reports THIS as the promise: the boosted figure (3.7x after
226
+ # three rounds) is a tuning artefact, not what was authored.
227
+ authored_magnitude: float | None = None
228
+ # How hard the engine pushes a BOUNDED measure's position in its band for
229
+ # this insight, on top of `magnitude`. Never authored: the tuning loop
230
+ # raises it instead of the magnitude when the measure is a rate / score /
231
+ # share whose band caps what any promise can deliver β€” inflating the
232
+ # promise there only moved the pass bar out of reach (findings triage
233
+ # 2026-10-15, B2). 1.0 = the magnitude alone.
234
+ drive_boost: float = 1.0
235
+
236
+
237
+ #: An insight window must leave at least this share of the history BEFORE it,
238
+ #: so a trend break has a "before" to compare against (findings triage
239
+ #: 2026-10-15, B3: windows at or before the first generated date gave
240
+ #: "1946 target / 0 baseline").
241
+ WINDOW_MIN_HISTORY_BEFORE = 1.0 / 3.0
242
+
243
+
244
+ def _parse_iso(value: str) -> date | None:
245
+ """An ISO date, or None for anything else (an LLM-invented relative token)."""
246
+ if not value:
247
+ return None
248
+ try:
249
+ return date.fromisoformat(str(value).strip())
250
+ except (ValueError, TypeError):
251
+ return None
252
 
253
 
254
  @dataclass(slots=True)
 
260
  include_date_dimension: bool = False
261
  fiscal_or_custom_calendar: str = "" # non-empty forces include_date_dimension
262
 
263
+ def span(self, today: date | None = None) -> tuple[date, date]:
264
+ """(first, last) generated date. THE definition of the history the
265
+ engine writes: months_of_history months ending on the last day of last
266
+ month (engine._date_spine reads it from here), so a window checked at
267
+ blueprint time and the rows generated later agree to the day."""
268
+ today = today or date.today()
269
+ end = today.replace(day=1) - timedelta(days=1)
270
+ start = end - timedelta(days=int(self.months_of_history * 30.44))
271
+ return start, end
272
+
273
+ def min_window_start(self, today: date | None = None) -> date:
274
+ """The earliest window_start that still leaves WINDOW_MIN_HISTORY_BEFORE
275
+ of the history as the 'before' baseline."""
276
+ start, end = self.span(today)
277
+ return start + timedelta(days=int((end - start).days * WINDOW_MIN_HISTORY_BEFORE))
278
+
279
+ def default_window_start(self, today: date | None = None) -> date:
280
+ """Where a missing or unusable window opens: the first of the month that
281
+ begins the last third of the history. Replaces the hard-coded
282
+ 2025-06-01, which with 12 months of history sat before the data began."""
283
+ start, end = self.span(today)
284
+ return (start + timedelta(days=(end - start).days * 2 // 3)).replace(day=1)
285
+
286
 
287
  @dataclass(slots=True)
288
  class DemoBlueprint:
 
317
  `[BOUND-WARN]` hardening lines, so each record carries the measure, the
318
  table and the concrete declaration to add β€” a log line cannot.
319
 
320
+ Severity `info` (boone, 2026-10-20): an uninferable range means our
321
+ name inference missed a vocabulary β€” a registry gap to fix in code, not
322
+ a defect in this build's data. The records are kept (demo_findings, the
323
+ run log) and Slack rolls them into one line per build instead of one
324
+ warning per measure, which is how ~600 of 662 warnings posted in three
325
+ days came from this one code. They never reach the structural-abort
326
+ path (see `problems()`, which never contains bound text).
327
  """
328
  from demoprep_app.dataset.measure_bounds import infer_bound
329
  from demoprep_app.quality.findings import Finding
 
338
  continue
339
  findings.append(Finding(
340
  code="bound_not_inferable",
341
+ severity="info",
342
  stage="blueprint",
343
  message=bound.gap,
344
  suggestion=bound.suggestion or bound.suggest_fix(m.name),
 
547
  fixes.append(f"{m.name}: flag measure -> fmt 'int'")
548
  m.fmt = "int"
549
 
550
+ # --- 1b. an open ratio is not a percentage ---
551
+ # COMBINED_RATIO declared fmt "pct" would be clamped to a 0-1 / 0-100
552
+ # scale at storage time (engine._pct_bound); a coverage or combined
553
+ # ratio legitimately exceeds 1 (findings triage 2026-10-15, C1).
554
+ for m in fact.measures:
555
+ if m.fmt == "pct" and is_open_ratio_name(m.name):
556
+ m.fmt = "float"
557
+ fixes.append(
558
+ f"{m.name}: fmt 'pct' -> 'float' (an open ratio β€” coverage, combined, "
559
+ "turnover, leverage β€” legitimately exceeds 1 and must not be clamped to "
560
+ "a percentage scale)"
561
+ )
562
+
563
  # --- 2. infer share_of from PART / WHOLE formulas ---
564
  # Which `A / B` is a part over its whole is measure_bounds'
565
  # answer, the same one the post-load gate checks against.
566
  shares = {m.name: m.share_of for m in fact.measures if m.share_of}
567
+ unbounded_reasons: list[str] = []
568
+
569
+ def _make_share(part, whole, ratio, via: str) -> bool:
570
+ """PART becomes a share_of WHOLE; False, with the reason recorded,
571
+ when it cannot (findings triage 2026-10-15, C2: the reason a rate
572
+ is left to the MIN/MAX wrap β€” and to pile up on its cap β€” was
573
+ invisible before)."""
574
+ if part is None or whole is None or part is whole:
575
+ return False # cross-fact / unknown: a formula problem, not a bound
576
+ if part.name.upper().endswith("_FLAG"):
577
+ return False # a flag is already 0/1
578
+ if part.kind != "base":
579
+ unbounded_reasons.append(f"{part.name} is derived ('{part.formula}'), not sampled")
580
+ return False
581
+ if part.share_of:
582
+ unbounded_reasons.append(f"{part.name} is already a share of {part.share_of}")
583
+ return False
584
+ if _share_of_cycle(part.name, whole, by_name):
585
+ unbounded_reasons.append(f"{part.name} as a share of {whole.name} would be circular")
586
+ return False
587
+ part.share_of = whole.name
588
+ shares[part.name] = whole.name
589
+ low, high = _inferred_fraction_range(part, whole)
590
+ part.params["low"], part.params["high"] = low, high
591
+ if part.per:
592
+ fixes.append(f"{part.name}: dropped per '{part.per}' (now a share of {whole.name})")
593
+ part.per = ""
594
+ fixes.append(
595
+ f"{part.name}: inferred share_of {whole.name} from {ratio.name} = '{ratio.formula}'"
596
+ f"{via} (fraction {low:.2f}-{high:.2f}) so {ratio.name} stays <= 1"
597
+ )
598
+ return True
599
+
600
  for m in fact.measures:
601
  if m.kind != "derived" or not m.formula:
602
  continue
603
  ratio_bound = infer_bound(m.name, m.fmt, m.params, signed=m.signed, formula=m.formula)
604
+ unbounded_reasons.clear()
605
+ bounded = False
606
+ pairs = part_whole_pairs(m.formula, ratio_bound, shares=shares)
607
+ for part_name, whole_name in pairs:
608
+ part, whole = by_name.get(part_name), by_name.get(whole_name)
609
  if part is None or whole is None or part is whole:
 
 
 
 
 
 
610
  continue
611
+ if part.kind == "derived" and part.formula:
612
+ # GROSS_MARGIN_RATE = GROSS_MARGIN / NET_SALES where
613
+ # GROSS_MARGIN = NET_SALES - COGS: bound the difference's
614
+ # subtrahend as a share of the whole, so the numerator is
615
+ # in [0, whole] by construction and the ratio in [0, 1].
616
+ # Loblaw's GROSS_MARGIN_RATE piled up on its cap because
617
+ # COGS was sampled independently of NET_SALES (C2).
618
+ hit = False
619
+ for minuend, subtrahend in formula_difference_pairs(part.formula):
620
+ if minuend == whole.name and _make_share(
621
+ by_name.get(subtrahend), whole, m,
622
+ f" via {part.name} = '{part.formula}'"):
623
+ hit = True
624
+ if hit:
625
+ bounded = True
626
+ else:
627
+ unbounded_reasons.append(
628
+ f"{part.name} is derived ('{part.formula}') and not a difference "
629
+ f"off {whole.name}"
630
+ )
631
+ continue
632
+ if _make_share(part, whole, m, ""):
633
+ bounded = True
634
+ # The margin written inline: (NET_SALES - COGS) / NET_SALES.
635
+ if ratio_bound is not None and ratio_bound.kind == "rate":
636
+ for whole_name, subtrahend in _margin_pairs(m.formula):
637
+ if _make_share(by_name.get(subtrahend), by_name.get(whole_name), m,
638
+ f" (a margin of {whole_name})"):
639
+ bounded = True
640
+ if (ratio_bound is not None and ratio_bound.kind == "rate" and not bounded
641
+ and (pairs or formula_ratio_pairs(m.formula) or _margin_pairs(m.formula))):
642
+ # Say WHY the rate is left to the wrap: the next pile-up on
643
+ # this measure then names its cause instead of "lower
644
+ # params.mean" (C2, Turtle / Loblaw / Panama 2026-10).
645
+ why = "; ".join(unbounded_reasons) or "no PART / WHOLE pair of sampled measures"
646
  fixes.append(
647
+ f"[BOUND-NOTE] {m.name} = '{m.formula}' could not be bounded structurally "
648
+ f"({why}); it is wrapped in MIN/MAX and rows on the cap are reported"
649
  )
650
 
651
  fixes.extend(self._differentiate_dimension_performance())
 
929
  self.metadata.setdefault("autorepairs", []).extend(fixes)
930
  return fixes
931
 
932
+ def ensure_insight_support(
933
+ self, rows_per_fact: int, *, min_rows: int = 20, margin: float = 1.5
934
+ ) -> list[str]:
935
+ """Give every planted insight enough rows to be MEASURED, before generation.
936
+
937
+ The validator needs `min_rows` rows on each side of a comparison
938
+ (validator.MIN_ROWS_PER_SIDE). A segment that is one driver in eight on
939
+ a 500-row fact gets ~60 rows; one with mix_weight 0.3 gets ~20 and the
940
+ insight comes back "too few rows to demo" β€” Humpty Dump's driver logs,
941
+ 4 / 51 and 13 / 54 (findings triage 2026-10-15, B3). The tuning loop
942
+ used to discover this after a full generation and bump mix_weight by
943
+ 1.5x per round. Estimating the rows from the mix share up front sizes
944
+ the segment once, from the number of rows the engine will write.
945
+
946
+ For a trend break the smaller of the in-window and before shares
947
+ decides; the window itself is placed by autorepair() so the before
948
+ side is at least a third of the history. A seasonal spike has no
949
+ segment to resize. Never lets one member take more than 80% of the
950
+ rows, and caps mix_weight at 5.0 like the tuning loop does.
951
+ """
952
+ fixes: list[str] = []
953
+ start, end = self.date.span()
954
+ span_days = max((end - start).days, 1)
955
+ dims = {d.name: d for d in self.dimensions}
956
+ need = min_rows * margin
957
+ for insight in self.insights:
958
+ if insight.insight_type == "seasonal_spike":
959
+ continue
960
+ dim = dims.get(insight.dimension)
961
+ value = next((v for v in (dim.values if dim else []) if v.name == insight.dimension_value), None)
962
+ if dim is None or value is None:
963
+ continue
964
+ weights = {v.name: max(0.01, float(v.mix_weight)) for v in dim.values}
965
+ others = sum(w for n, w in weights.items() if n != value.name)
966
+ if others <= 0:
967
+ continue
968
+ side = 1.0
969
+ if insight.insight_type == "trend_break":
970
+ ws = _parse_iso(insight.window_start)
971
+ if ws is not None:
972
+ in_window = min(max((end - max(ws, start)).days / span_days, 0.0), 1.0)
973
+ side = min(in_window, 1.0 - in_window)
974
+ if side <= 0:
975
+ continue # the window is the problem, not the mix β€” autorepair moves it
976
+ share = weights[value.name] / (weights[value.name] + others)
977
+ expected = rows_per_fact * share * side
978
+ if expected >= need:
979
+ continue
980
+ wanted_share = min(need / (rows_per_fact * side), 0.8)
981
+ new_weight = min(round(wanted_share * others / (1.0 - wanted_share), 3), 5.0)
982
+ if new_weight <= weights[value.name]:
983
+ continue
984
+ old = value.mix_weight
985
+ value.mix_weight = new_weight
986
+ fixes.append(
987
+ f"insight {insight.id}: {insight.dimension_value} would get ~{expected:.0f} rows "
988
+ f"of {rows_per_fact} (the validator needs {min_rows} on each side) -> "
989
+ f"mix_weight {old:g} -> {new_weight:g}"
990
+ )
991
+ if fixes:
992
+ self.metadata.setdefault("insight_support", []).extend(fixes)
993
+ return fixes
994
+
995
  def findings(self) -> list[Finding]:
996
  """Validation report as records: every rule decides its OWN severity.
997
 
 
1154
  critical("measure_kind_invalid", f"Measure {m.name}: kind must be 'base' or 'derived'.",
1155
  suggestion="autorepair infers the kind from the formula",
1156
  table=fact.name, measure=m.name)
1157
+ if (m.kind == "base" and not m.share_of and is_score_column(m.name)
1158
+ and not {"low", "high"} <= set(m.params)
1159
+ and named_score_scale(m.name, m.params) is None):
1160
+ # A score/index/rating is bounded by definition, and the
1161
+ # scale is the author's to state: a mean alone picks the
1162
+ # smallest standard ladder step that fits (a guess), and no
1163
+ # mean at all leaves the range uninferable (findings triage
1164
+ # 2026-10-15, A4: UPMC's LENGTH_OF_STAY_INDEX, PwC's
1165
+ # AVG_TRUST_SCORE). The repair round fixes it; a survivor
1166
+ # ships with this warning.
1167
+ level = m.params.get("mean")
1168
+ hint = (f"; a mean of {level:g} alone only guesses the scale"
1169
+ if isinstance(level, (int, float)) else
1170
+ "; with no mean either, the range cannot be inferred at all")
1171
+ warn("measure_score_range_missing",
1172
+ f"Measure {m.name}: a score / index / rating must declare its scale as "
1173
+ f"params.low and params.high (e.g. 0-100, 1-5, 0-10){hint}.",
1174
+ suggestion=(f"declare params.low/high on {m.name}; otherwise the range is "
1175
+ "inferred from params.mean or reported as not inferable"),
1176
+ table=fact.name, measure=m.name)
1177
  if m.fmt not in VALID_FORMATS:
1178
  critical("measure_fmt_invalid", f"Measure {m.name}: fmt must be one of {VALID_FORMATS}.",
1179
  suggestion="autorepair coerces it to float", table=fact.name, measure=m.name)
 
1196
  if len(self.insights) < 3:
1197
  warn("insights_too_few", "Provide at least 3 planted insights β€” these are the demo story.",
1198
  suggestion="autorepair synthesizes segment_outlier insights from the base measures")
1199
+ span_start, span_end = self.date.span()
1200
+ earliest_window = self.date.min_window_start()
1201
  for i in self.insights:
1202
  if i.insight_type not in VALID_INSIGHT_TYPES:
1203
  critical("insight_type_invalid",
 
1225
  f"Insight {i.id}: dimension_value '{i.dimension_value}' is not a value of {i.dimension}.",
1226
  suggestion="autorepair remaps it to an existing value",
1227
  table=i.dimension, measure=i.measure)
1228
+ # Windows are checked against the SPAN the engine will generate
1229
+ # (findings triage 2026-10-15, B3): a window before the data starts
1230
+ # has no 'before', one after it ends has no rows at all β€” the
1231
+ # validator then reports "too few rows" (Verizon 1946 / 0, AMD
1232
+ # 0 / 0, BCD 0 / 0).
1233
+ ws = _parse_iso(i.window_start) if i.window_start else None
1234
+ we = _parse_iso(i.window_end) if i.window_end else None
1235
+ if i.insight_type in ("trend_break", "seasonal_spike"):
1236
+ if not i.window_start:
1237
+ warn("insight_window_missing",
1238
+ f"Insight {i.id}: {i.insight_type} requires window_start (ISO date).",
1239
+ suggestion="autorepair adds a default window_start", measure=i.measure)
1240
+ elif ws is None:
1241
+ warn("insight_window_invalid",
1242
+ f"Insight {i.id}: window_start '{i.window_start}' is not an ISO date "
1243
+ "(YYYY-MM-DD).",
1244
+ suggestion="autorepair moves it to the start of the last third of the history",
1245
+ measure=i.measure)
1246
+ elif ws > span_end:
1247
+ warn("insight_window_outside_span",
1248
+ f"Insight {i.id}: window_start {ws} is after the last generated date "
1249
+ f"({span_end}); the history spans {span_start}..{span_end}. Use a date "
1250
+ "inside it.",
1251
+ suggestion="autorepair moves it to the start of the last third of the history",
1252
+ measure=i.measure)
1253
+ elif ws < earliest_window:
1254
+ warn("insight_window_too_early",
1255
+ f"Insight {i.id}: window_start {ws} leaves less than a third of the history "
1256
+ f"before it (data starts {span_start}), so there is nothing to compare "
1257
+ f"against; use a date on or after {earliest_window}.",
1258
+ suggestion="autorepair moves it to the start of the last third of the history",
1259
+ measure=i.measure)
1260
+ elif i.window_start or i.window_end:
1261
+ if (i.window_start and (ws is None or ws > span_end)) or (
1262
+ i.window_end and (we is None or we < span_start)):
1263
+ warn("insight_window_outside_span",
1264
+ f"Insight {i.id}: window {i.window_start or '…'}..{i.window_end or '…'} "
1265
+ f"does not overlap the generated history {span_start}..{span_end}; a "
1266
+ "segment insight with a window outside it has no rows on either side.",
1267
+ suggestion="autorepair drops the window so the whole history is compared",
1268
+ measure=i.measure)
1269
+ if i.window_end and (we is None or (ws is not None and we <= ws)):
1270
+ warn("insight_window_end_invalid",
1271
+ f"Insight {i.id}: window_end '{i.window_end}' must be an ISO date after "
1272
+ f"window_start.",
1273
+ suggestion="autorepair drops window_end", measure=i.measure)
1274
  if not (i.magnitude >= 1.15 or i.magnitude <= 0.85):
1275
  warn("insight_too_subtle",
1276
  f"Insight {i.id}: magnitude {i.magnitude} is too subtle to demo. Use >=1.15 or <=0.85.",
 
1388
  # --- insights ---
1389
  all_measure_names = {m.name for f in self.facts for m in f.measures}
1390
  base_measures = [m.name for f in self.facts for m in f.measures if m.kind == "base"]
1391
+ span_start, span_end = self.date.span()
1392
+ earliest_window = self.date.min_window_start()
1393
+ default_window = self.date.default_window_start().isoformat()
1394
  kept: list[PlantedInsight] = []
1395
  for i in self.insights:
1396
  if i.insight_type not in VALID_INSIGHT_TYPES:
 
1423
  else:
1424
  fixes.append(f"insight {i.id}: dropped (dimension has no values)")
1425
  continue
1426
+ # Windows live inside the history the engine will generate and
1427
+ # leave a 'before' (findings triage 2026-10-15, B3). The old default
1428
+ # was a hard-coded 2025-06-01, which with 12 months of history sat
1429
+ # before the first generated date.
1430
+ ws = _parse_iso(i.window_start) if i.window_start else None
1431
+ we = _parse_iso(i.window_end) if i.window_end else None
1432
+ if i.insight_type in ("trend_break", "seasonal_spike"):
1433
+ if ws is None or ws > span_end or ws < earliest_window:
1434
+ why = ("missing" if not i.window_start else "unreadable" if ws is None
1435
+ else "after the data ends" if ws > span_end
1436
+ else "too early to leave a 'before' baseline")
1437
+ i.window_start = default_window
1438
+ ws = _parse_iso(default_window)
1439
+ fixes.append(
1440
+ f"insight {i.id}: window_start {why} -> {default_window} (history "
1441
+ f"{span_start}..{span_end}; the window opens its last third)"
1442
+ )
1443
+ if i.window_end and (we is None or we <= ws or we > span_end):
1444
+ fixes.append(f"insight {i.id}: dropped window_end '{i.window_end}' (not after "
1445
+ "window_start, or past the data)")
1446
+ i.window_end = ""
1447
+ elif i.window_start or i.window_end:
1448
+ if (i.window_start and (ws is None or ws > span_end)) or (
1449
+ i.window_end and (we is None or we < span_start or (ws and we <= ws))):
1450
+ fixes.append(
1451
+ f"insight {i.id}: window {i.window_start or '…'}..{i.window_end or '…'} "
1452
+ f"is outside the history {span_start}..{span_end} -> whole history"
1453
+ )
1454
+ i.window_start = i.window_end = ""
1455
  if not (i.magnitude >= 1.15 or i.magnitude <= 0.85):
1456
  i.magnitude = 1.3
1457
  fixes.append(f"insight {i.id}: magnitude -> 1.3")
 
1554
  if bound.kind != "rate" or abs(bound.scale - 1.0) > 1e-9:
1555
  return False
1556
  pairs = formula_ratio_pairs(measure.formula or "")
1557
+ margins = _margin_pairs(measure.formula or "")
1558
+ if not pairs and not margins:
1559
  return False
1560
  for part_name, whole_name in pairs:
1561
  part = by_name.get(part_name)
1562
+ if part is None:
1563
+ return False
1564
+ if part.share_of == whole_name:
1565
+ continue
1566
+ # A derived numerator WHOLE - X with X a share of WHOLE sits in
1567
+ # [0, WHOLE] by construction (harden() step 2 installs that share).
1568
+ diffs = formula_difference_pairs(part.formula or "") if part.kind == "derived" else []
1569
+ if diffs and all(
1570
+ minuend == whole_name and getattr(by_name.get(sub), "share_of", "") == whole_name
1571
+ for minuend, sub in diffs
1572
+ ):
1573
+ continue
1574
+ return False
1575
+ for whole_name, subtrahend in margins:
1576
+ if getattr(by_name.get(subtrahend), "share_of", "") != whole_name:
1577
  return False
1578
  return True
1579
 
1580
 
1581
+ def _margin_pairs(formula: str) -> list[tuple[str, str]]:
1582
+ """Every `(W - X) / W` in a formula, as (W, X) β€” the margin shape.
1583
+
1584
+ `ratio_pairs` sees only `A / B` of two bare identifiers; a gross margin
1585
+ rate written inline, `(NET_SALES - COGS) / NET_SALES`, has a difference as
1586
+ its numerator. Bounding X as a share of W keeps the ratio in [0, 1] by
1587
+ construction, so this is the shape harden() looks for next.
1588
+ """
1589
+ try:
1590
+ tree = ast.parse((formula or "").strip() or "0", mode="eval")
1591
+ except SyntaxError:
1592
+ return []
1593
+ pairs: list[tuple[str, str]] = []
1594
+ for node in ast.walk(tree):
1595
+ if (
1596
+ isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div)
1597
+ and isinstance(node.right, ast.Name)
1598
+ and isinstance(node.left, ast.BinOp) and isinstance(node.left.op, ast.Sub)
1599
+ and isinstance(node.left.left, ast.Name) and isinstance(node.left.right, ast.Name)
1600
+ and node.left.left.id == node.right.id
1601
+ ):
1602
+ pairs.append((node.right.id, node.left.right.id))
1603
+ return pairs
1604
+
1605
+
1606
  def _wrap_formula_in_bound(formula: str, bound: MeasureBound) -> str:
1607
  """`f` -> `MIN(MAX(f, low), high)` unless it is already wrapped that way.
1608
 
demoprep_app/scenario/blueprint_author.py CHANGED
@@ -186,7 +186,12 @@ The demo must let a sales engineer type natural-language questions live and DISC
186
  interesting, believable findings. You control what those findings are: plant 3-5 insights
187
  that a {demo_audience_hint} would care about, each with the exact Spotter question that
188
  reveals it. Insights must be big enough to see on a chart (magnitude >= 1.15 or <= 0.85)
189
- and grounded in this company's actual world.
 
 
 
 
 
190
 
191
  ## Hard requirements
192
  1. Dimension values must be concrete domain nouns from THIS company's world (real-sounding
@@ -224,6 +229,12 @@ and grounded in this company's actual world.
224
  per-transaction draw. Set "per": "<ENTITY_DIMENSION>" (e.g. UNIT_PRICE with "per":"PRODUCT")
225
  so every row for that product carries the same price. Without it, the same product shows
226
  wildly varying prices and margins row to row and reads as random noise, not real data.
 
 
 
 
 
 
227
  4. Monthly seasonality multipliers (all 12 months) that fit this business, plus an annual
228
  trend percentage.
229
  5. date.include_date_dimension: set true ONLY if the scenario needs custom calendar
 
186
  interesting, believable findings. You control what those findings are: plant 3-5 insights
187
  that a {demo_audience_hint} would care about, each with the exact Spotter question that
188
  reveals it. Insights must be big enough to see on a chart (magnitude >= 1.15 or <= 0.85)
189
+ and grounded in this company's actual world. A trend_break or seasonal_spike window_start
190
+ must fall INSIDE the generated history (the last months_of_history months, ending last
191
+ month) and leave at least the first third of it as the "before" baseline β€” a window that
192
+ starts before the data does has nothing to compare against. A dimension_value that carries
193
+ a planted insight needs enough rows to show (at least 20 on each side of the comparison):
194
+ give it mix_weight 1.0 or more when its dimension has many members.
195
 
196
  ## Hard requirements
197
  1. Dimension values must be concrete domain nouns from THIS company's world (real-sounding
 
229
  per-transaction draw. Set "per": "<ENTITY_DIMENSION>" (e.g. UNIT_PRICE with "per":"PRODUCT")
230
  so every row for that product carries the same price. Without it, the same product shows
231
  wildly varying prices and margins row to row and reads as random noise, not real data.
232
+ 3c. Any SCORE / INDEX / RATING measure (a health score, a risk index, CSAT, a trust score)
233
+ MUST declare its scale as params.low and params.high (0-100, 1-5, 0-10, 0-4 ...). A score
234
+ with no declared range cannot be range-checked, and no scale is ever guessed. A SUM or
235
+ COUNT of scores is a quantity, not a score β€” name it with _SUM / _COUNT. A ratio of two
236
+ DIFFERENT quantities (pipeline coverage, combined ratio, inventory turns, book-to-bill)
237
+ legitimately exceeds 1: give it fmt "float", not "pct".
238
  4. Monthly seasonality multipliers (all 12 months) that fit this business, plus an annual
239
  trend percentage.
240
  5. date.include_date_dimension: set true ONLY if the scenario needs custom calendar
docs/PLAN_findings_triage_2026_10_15.md ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Findings triage β€” 47 builds, 2026-10-12 β†’ 10-15 β€” remediation plan
2
+
3
+ **Status:** proposal for boone, 2026-10-15. Nothing implemented yet.
4
+ **Source:** the DemoPrep Slack channel for Oct 12 15:40 β†’ Oct 15 12:19 ET (47 build
5
+ messages, 21 SEs), cross-checked against the code and Supabase `session_logs`.
6
+ **Branch:** `plan/findings-triage-2026-10` (cut from `integrate/bounds-gates` @ 56241f3).
7
+
8
+ ---
9
+
10
+ ## 1. What three days of Slack traffic say
11
+
12
+ | | Count | Share |
13
+ |---|---|---|
14
+ | Build messages | 47 (43 shipped, 4 failed at ThoughtSpot deploy) | |
15
+ | Warnings posted | β‰ˆ662 | |
16
+ | `bound_not_inferable` β€” "no bound class matched X" | β‰ˆ600 | **β‰ˆ91 %** |
17
+ | `bound_not_inferable` β€” score/index with no scale | 8 (2 duplicate pairs) | 1 % |
18
+ | `insight_weak` | 40 visible, a few more under the fold | 6 % |
19
+ | `measure_bound_pileup` | 6 | 1 % |
20
+ | `repair_failed` + `measure_mirrors_other_column` | 2 (same measure) | <1 % |
21
+ | Deploy failures ("ThoughtSpot authentication failed") | 4 runs | |
22
+ | Builds truncated with "… and N more" | 29 of 47 | |
23
+
24
+ Median warnings per build β‰ˆ15; Slack prints 12. Because the list is sorted
25
+ alphabetically by code inside a severity, `bound_not_inferable` fills the 12
26
+ slots and the findings that actually matter (`insight_weak`, pile-ups, the
27
+ mirrored column) are the ones that fall under "… and N more". The signal is
28
+ there; it is being drowned by one noisy family.
29
+
30
+ ## 2. Where the code that produced these lives
31
+
32
+ * Prod MCP runs **`hf-mcp/main` @ d139d21** (fetched 10-15). That branch holds
33
+ `slack_notifier.format_build_findings` (the "build shipped with N warning(s)"
34
+ post) β€” this checkout does not.
35
+ * This checkout was **`integrate/bounds-gates` @ 56241f3**. Merge-base with prod
36
+ is 33512e4; **neither branch contains the other**, and the eight files this
37
+ plan touches differ by β‰ˆ1,400 lines (`engine.py`, `measure_bounds.py`,
38
+ `measure_names.py`, `quality_gate.py`, `build_demo.py`, `blueprint.py`,
39
+ `slack_notifier.py`, `thoughtspot_deployer.py`).
40
+ * Every root cause below was verified on **both** branches unless noted.
41
+
42
+ This is the first decision: see Β§6.
43
+
44
+ ## 3. Finding families β†’ root cause β†’ fix
45
+
46
+ ### A. `bound_not_inferable` flood (β‰ˆ600 warnings)
47
+
48
+ **Root cause 1 β€” the post-load gate throws the format away.**
49
+ `quality_gate.semantic_bound()` calls
50
+ `infer_bound(column_name, "", {"mean": avg}, formula=formula)` with `fmt=""`
51
+ *on purpose* (its docstring: format-derived non-negativity "would flag every
52
+ legitimate negative margin column"). But `infer_bound` already checks
53
+ `is_signed_name()` and `_formula_is_signed()` *before* the `fmt in ("int",
54
+ "currency")` rule, so that concern is handled β€” and the blueprint's own
55
+ `signed` flag is available too. Result: every integer count (`SESSIONS`,
56
+ `ORDERS_SHIPPED`, `CASES_PICKED`, …) and every money column (`COGS`,
57
+ `AUM_USD`, `GMV`, …) comes back `None` and is posted as a vocabulary gap.
58
+ The blueprint stage (`DemoBlueprint.bound_findings`) passes `fmt` and is quiet
59
+ for the same columns.
60
+
61
+ **Root cause 2 β€” "signed by design" is reported as "unclassified".**
62
+ `infer_bound` returns `None` both for `if name_signed: return None` (a
63
+ variance / net change has no bound, correctly) and for "nothing matched". The
64
+ gate cannot tell them apart, so `NET_CASH_FLOW_USD`, `GROSS_MARGIN`,
65
+ `OPERATING_MARGIN`, `SPEND_VARIANCE_USD`, `NET_ENROLLMENT_CHANGE` are all
66
+ posted as gaps.
67
+
68
+ **Root cause 3 β€” real vocabulary gaps** (second line of defence once fmt is
69
+ threaded; still the only line at the blueprint stage):
70
+ * `CURRENCY_TOKENS` / `_QUANTITY_UNIT_TOKENS` lack **CAD** (five Canadian
71
+ prospects in three days: OMERS, Loblaw, Honda Canada, Islamic Relief
72
+ Canada, Environics), AUD, DOLLARS.
73
+ * No count vocabulary at all (plural nouns, `_CREATED/_RESOLVED/_SHIPPED/…`).
74
+ * Money nouns missing: COGS, GMV, AUM, VALUE, COMMISSION, DISCOUNT, FARE,
75
+ INCENTIVE, MSRP, ASP, CAPITAL, INCOME, PIPELINE, RESERVES, COMPENSATION,
76
+ SAVINGS, AID, BUDGET, PAY, SURCHARGE, TARIFF, TOLL, LTV, REIMBURSEMENT.
77
+ * Physical units missing: FT, CM, CM2, KG, MS, YEARS, MILLIONS, BPS.
78
+ * Proportion words missing: EFFICIENCY (`THROUGHPUT_EFFICIENCY`), COMPLETENESS
79
+ (`LEAD_ATTRIBUTION_COMPLETENESS`), LOAD_FACTOR, AVAILABILITY.
80
+
81
+ **Root cause 4 β€” score/index measures.** `LENGTH_OF_STAY_INDEX` (UPMC),
82
+ `AVG_CSAT` (Verizon), `OIL_PRICE_TRANSIT_CORRELATION_INDEX` (Panama),
83
+ `AVG_TRUST_SCORE` (PwC): the LLM declared no `params.low/high`. Also
84
+ `CSAT_SCORE_SUM` is a *sum* of scores, not a score (the `_SUM` suffix is
85
+ already "explicitly additive" for aggregation but not for the score class);
86
+ `MEDIAN_SAT_SCORE` (1530) and `AVG_GPA` have no entry in `SCORE_SCALES`.
87
+
88
+ **Root cause 5 β€” duplicates.** `CSAT_SCORE_SUM` and `MEDIAN_SAT_SCORE` each
89
+ appear twice in one post: once from the blueprint stage (declared mean) and
90
+ once from post-load (observed mean), with different text, so `_dedupe` keeps
91
+ both.
92
+
93
+ **Fixes**
94
+ 1. Thread the blueprint's per-measure `fmt`, `kind`, `signed` into the
95
+ post-load gate the same way `formulas` already travel
96
+ (`chat_interface.py:3850` builds `_dq_formulas` keyed `TABLE.COLUMN`; add a
97
+ sibling map). `semantic_bound` then calls `infer_bound` with the real
98
+ format. Counts and money β†’ `non_negative`; nothing posted.
99
+ 2. Give `infer_bound` a distinguishable "signed, unbounded by design" result
100
+ (e.g. `MeasureBound(None, None, "signed", …)` with no `gap`) so neither
101
+ emitter treats it as a gap.
102
+ 3. Dedupe on `(code, table, measure)` across stages in `build_demo._dedupe`
103
+ and in `format_build_findings`; keep the post-load record (it has observed
104
+ stats).
105
+ 4. Reporting (decision, Β§6): demote `bound_not_inferable` to `info` and post a
106
+ single roll-up line per build ("7 measures unclassified: A, B, C …") placed
107
+ *last*; sort visible findings by a code priority (critical β†’ send-blocking β†’
108
+ pile-up / mirror / repair_failed β†’ insight_weak β†’ the rest) instead of
109
+ alphabetically.
110
+ 5. Vocabulary (A3) and score registry (A4): add the tokens above; add GPA
111
+ (0–4, 0–5 weighted), SAT (400–1600), ACT (1–36), CSAT (1–5 default) to the
112
+ scale ladder; exclude explicitly-additive shapes (`_SUM`, `_TOTAL`,
113
+ `TOTAL_`) from `is_score_column`; make the blueprint author prompt and
114
+ `problems()` require `low/high` on any SCORE/INDEX measure.
115
+
116
+ ### B. `insight_weak` (β‰ˆ45 warnings) β€” three different things
117
+
118
+ **B1 β€” the message is wrong, not the check.** `validator._record_findings`
119
+ prints `check.required_ratio` as "the N it promised". `required` is the pass
120
+ bar (`1 + (promised βˆ’ 1) Γ— MIN_REALIZED_FRACTION`, fraction = 0.5), not the
121
+ promise. For a laggard it reads backwards: "realized 0.83 of the 0.69 it
122
+ promised" means *needed ≀ 0.69 (the LLM promised 0.38), got 0.83* β€” the
123
+ segment is 17 % below baseline where the story needs 62 %. Nine of the 40
124
+ lines look like false positives and are not. Fix: print promised (as
125
+ authored), needed, realized and direction.
126
+
127
+ **B2 β€” unreachable promises on bounded measures.** `_boost_weak_insights`
128
+ multiplies the distance-from-1 by `BOOST_FACTOR = 1.6` for up to
129
+ `MAX_TUNING_ROUNDS = 3` rounds, and the Slack line shows the *boosted* promise
130
+ (that is where 3.69, 3.71, 4.07 come from). But for `share_of` / rate measures
131
+ the engine deliberately warps *within* the `[low, high]` band ("performance
132
+ moves where in the band this row lands, it does not push past it",
133
+ `engine.py` β‰ˆ390). A rate whose band is 0.05–0.85 cannot be 3.7Γ— its baseline,
134
+ so each round inflates the promise and nothing moves. Fix: for proportion /
135
+ score measures cap the promise at what the band can deliver and boost band
136
+ position / `mix_weight` instead of magnitude; stop boosting when realized did
137
+ not change between rounds (the rounds are deterministic); report the authored
138
+ promise.
139
+
140
+ **B3 β€” "too few rows to demo"** (7 cases, all pass through the same check in
141
+ `_check_insight`, threshold 20 rows each side):
142
+ * `0 target / 0 baseline` (AMD `Instinct MI300X`, BCD `LHR β†’ JFK`): segment
143
+ checks drop every row outside the window, so a window outside the generated
144
+ date span yields nothing on either side.
145
+ * `1946 / 0`, `1465 / 0`, `418 / 1` (Verizon, Fortegra, Turtle): trend-break
146
+ windows that start at or before the first generated date, so there is no
147
+ "before". `autorepair()` hard-codes `window_start = "2025-06-01"` when the
148
+ LLM omitted it (`blueprint.py:969`); with 12 months of history that is before
149
+ the data starts.
150
+ * `4 / 51`, `13 / 54` (Humpty Dump driver logs): small fact tables where one
151
+ driver in eight can never reach 20 rows.
152
+
153
+ `problems()` checks that `dimension_value` exists and that `window_start` is
154
+ present, but not that the window overlaps the date span or leaves a baseline.
155
+ Fix: validate windows against `DateSpec` at blueprint time (inside the span;
156
+ trend-break leaves β‰₯ β…“ of history before it; replace the hard-coded date with
157
+ "last third of the span"); estimate rows per side from `row_count Γ— mix share`
158
+ and raise `mix_weight` *before* generation when it is under 20; emit a distinct
159
+ code `insight_unmeasurable` with the cause instead of `insight_weak`.
160
+
161
+ ### C. `measure_bound_pileup` (6) β€” two different causes
162
+
163
+ **C1 β€” open ratios bounded to [0, 1].** `_BOUNDED_SUFFIXES` maps `_RATIO` to
164
+ (0, 1). `PIPELINE_COVERAGE_RATIO` (Bruker, **99.8 %** pinned β€” a constant
165
+ column on a shipped board), `COMBINED_RATIO` (Frontline, 51.8 %) and
166
+ `LOSS_RATIO` (48 %) are ratios of two quantities and legitimately exceed 1; an
167
+ insurance combined ratio over 100 % *is* the story. Fix: an open-ratio
168
+ vocabulary (COVERAGE, COMBINED, TURNOVER, LEVERAGE, MULTIPLE, TURNS,
169
+ BOOK_TO_BILL, LTV_CAC, CURRENT, QUICK, DEBT_TO …) that makes `_RATIO`
170
+ non-negative with no ceiling; for a derived `A / B`, bound to [0, 1] only when
171
+ `A` is structurally `share_of B`. Keep `is_rate_column` β†’ AVERAGE aggregation
172
+ for these (decision, Β§6).
173
+
174
+ **C2 β€” derived part/whole that is not `share_of`.** `FILL_RATE` (Turtle
175
+ 17.7 %), `GROSS_MARGIN_RATE` (Loblaw 23.6 %), `LAKE_CAPACITY_UTILIZATION_RATE`
176
+ (Panama 49.1 %): `_store` clamps a wrapped derived value at its cap
177
+ (`engine.py:65`, β‰ˆ480) because the numerator was sampled independently of the
178
+ denominator. `harden()` already has `_inferred_fraction_range` / share_of
179
+ inference for `A / B` β€” find why it did not apply here (numerator itself
180
+ derived? denominator a sum?) and extend it. The pile-up suggestion text should
181
+ also match the cause: today it says "lower params.mean", which a derived
182
+ measure does not have.
183
+
184
+ ### D. `repair_failed` + `measure_mirrors_other_column` (Humpty Dump run 2)
185
+
186
+ The LLM wrote `DAYS_ON_SITE = RENTAL_DAYS * 1`. `validate_formula` accepts it
187
+ (the identifier exists), the engine stores a copy, the post-load mirror check
188
+ catches it, the gate "repairs" by recomputing from the same formula, re-profiles,
189
+ still mirrors β†’ `repair_failed` (send-blocking) and the column is dropped. The
190
+ suggestion text ("references an identifier that is not a measure") is wrong
191
+ for this case. Jack built three times in 70 minutes to get past one measure.
192
+
193
+ Fix: identity-formula guard (`X`, `X * 1`, `1 * X`, `X / 1`, `X + 0`) in
194
+ `validate_formula` so `_repair_one_formula` demotes or drops it *before*
195
+ generation; in the gate, a `mirrors_other_column` defect goes straight to omit
196
+ (`_OMIT_WORTHY` already lists it) and never through a recompute; fix the
197
+ suggestion text.
198
+
199
+ ### E. Four deploy failures β€” not auth, provisioning
200
+
201
+ Supabase `session_logs` (event `auth failed`) for the four runs:
202
+
203
+ | Run | SE | ThoughtSpot response |
204
+ |---|---|---|
205
+ | Lead Forensics 10-13 07:43 UTC | patrick.case | HTTP 400, code 10002 `Specified user does not exist` |
206
+ | Rice University 10-13 20:58 UTC | kris.lefebvre | same |
207
+ | Lead Forensics 10-15 14:08 UTC | patrick.case | same |
208
+ | Lead Forensics 10-15 14:35 UTC | patrick.case | same |
209
+
210
+ All on `se-thoughtspot-cloud`. The trusted-auth token request is made *as the
211
+ owner*, and these two SEs have no user there. Each run did the whole build
212
+ (research, data, Snowflake load β€” schemas `LEA_10151433_N6I`,
213
+ `RIC_10132058_S9S` exist) before failing, and the MCP result / Slack post said
214
+ only "ThoughtSpot authentication failed" plus a traceback, so Patrick retried
215
+ twice over two days with the same outcome. Lead Forensics was eventually built
216
+ by max.lough on 10-15; Rice by paul.gilman and martha.miller.
217
+
218
+ `ThoughtSpotDeployer.authenticate_trusted` already captures
219
+ `last_auth_error` / `last_auth_status_code`; `deploy_all` (β‰ˆ2862) raises a bare
220
+ `Exception("ThoughtSpot authentication failed")` and the detail goes to the
221
+ session log only. `mcp_server._run_build` has no pre-flight.
222
+
223
+ Fix:
224
+ 1. Pre-flight `authenticate_trusted()` as the owner at the top of `_run_build`
225
+ (before research) β€” fail in seconds, not after a Snowflake load.
226
+ 2. Carry `last_auth_error` + status + user + env into the exception, the MCP
227
+ result and the Slack post; map code 10002 to a plain instruction ("user X
228
+ does not exist on <env> β€” ask the SE admin to create it, or pass a different
229
+ `ts_url`").
230
+ 3. A `deploy_existing_schema(schema, owner_email, ts_url)` MCP tool so a
231
+ partial build is deployable after the user is created, without regenerating.
232
+ 4. **Ops, now:** get patrick.case and kris.lefebvre created on
233
+ se-thoughtspot-cloud (or confirm their intended cluster) and tell them why
234
+ the builds failed.
235
+
236
+ ### F. Small hygiene
237
+
238
+ * `supabase_client.py:801–903` truncates `company_name` / `use_case` at 100
239
+ chars and `demo_builder_class.py:199` at 30 β€” "OMERS Administration Corporati",
240
+ "…with futu", "…distribution busines". Widen to 255 / text; strip whitespace
241
+ ("Humpty Dump Roll Off Services ").
242
+ * Board links switched to `/#/insights/pinboard/` from 10-14 17:31 ET
243
+ (ThoughtSpot UI change). Confirm every link builder and the portal accept
244
+ both forms.
245
+ * Virtuoso was built on `sebe.thoughtspotstaging.cloud`; the reviewer key
246
+ cannot export TML there (per `DAILY_RUN_REPORT.md`), so it grades on data
247
+ only. Not a defect, just a known gap.
248
+
249
+ ## 4. Sequencing
250
+
251
+ **Wave 1 β€” stop the noise, show the real failures** (one PR, β‰ˆ1 day)
252
+ A1 fmt threading Β· A2 signed tri-state Β· A3-dedupe Β· A4 severity/roll-up/order
253
+ Β· B1 message Β· D identity-formula guard Β· E1 pre-flight Β· E2 real auth error
254
+ Β· ops: provision the two users.
255
+
256
+ **Wave 2 β€” bounds correctness** (β‰ˆ2 days)
257
+ A5 vocabulary + score registry + author prompt Β· C1 open ratios Β· B3 window
258
+ validation + `insight_unmeasurable` Β· C2 suggestion text.
259
+
260
+ **Wave 3 β€” generation calibration** (β‰ˆ2–3 days)
261
+ B2 boost/band calibration Β· C2 share_of inference for derived rates Β· E3
262
+ redeploy tool Β· F hygiene.
263
+
264
+ ## 5. How we will know
265
+
266
+ * Replay the 2026-10 acceptance suite (`tests/` β€” 4 builds, 9 fix families)
267
+ plus the Turtle, Frontline, Verizon and Humpty-Dump-2 blueprints:
268
+ warnings per build ≀ 5; zero `bound_not_inferable` for `fmt` int/currency;
269
+ zero duplicate `(code, measure)`; every `insight_weak` line shows
270
+ promised / needed / realized; a pile-up only when a bounded rate truly pins.
271
+ * A build for an unprovisioned owner fails inside 10 s with the user and
272
+ environment named.
273
+ * Tests to extend: `test_measure_bounds.py`, `test_flat_measure_gate.py`,
274
+ `test_blueprint_measure_hardening.py`, `test_measure_formula.py`,
275
+ `test_build_abort_policy.py` (on `hf-mcp/main`), validator tests.
276
+
277
+ ## 6. Decisions β€” made 2026-10-20 (boone: "I want it all in test asap")
278
+
279
+ 1. **Base branch.** The TEST line: `hf-mcp-test/main` @ c0c09a2 (contains
280
+ prod d139d21 plus the 10-13 β†’ 10-20 test work). All three waves land on one
281
+ branch, `feature/findings-triage`, built in the "Wave 1: stop the warning
282
+ noise" session's worktree, then pushed to `hf-mcp-test` only. Prod is
283
+ boone's call later.
284
+ 2. **`bound_not_inferable` severity.** `info` + one roll-up line per build.
285
+ 3. **`_RATIO` default.** Keep (0, 1); add an open-ratio veto list.
286
+ 4. **Provisioning.** DemoPrep never auto-creates ThoughtSpot users. The two
287
+ SE accounts (patrick.case, kris.lefebvre) are an admin task on
288
+ se-thoughtspot-cloud.
289
+
290
+ ## 7. Status β€” 2026-10-20: Waves 1–3 implemented, not pushed
291
+
292
+ Branch `feature/findings-triage` (cut from `hf-mcp-test/main` @ c0c09a2, the test
293
+ line). Commits: ISS-22 login precheck cherry-picked (30d7d85), Wave 1 c6a9e6a,
294
+ Wave 2 1e97c45, Wave 3 (see `git log`). Suite: 1402 passed / 5 skipped / 3 failed β€” the 3 pre-existing test-line failures (`test_iss12_flat_measures::test_no_pile_up_at_the_band_edges`, `test_kpi_sparkline_comparison::...sparkline_state`, `test_liveboard_creation_context::...matrix_config`), plus the pre-existing collection error in `tests/test_blank_threshold.py` (`pymupdf` is not in `.venv`); baseline on c0c09a2 was 1178 passed. Details per wave in
295
+ `sprint_2026_04.md` (Done β†’ "Findings triage, Waves 1–3").
296
+
297
+ | Item | State |
298
+ |---|---|
299
+ | A1 fmt threading | done β€” `DatasetColumn.fmt/signed/measure_kind`, `measure_formats`, gate `formats=` (one call site serves App, Chat and MCP) |
300
+ | A2 signed tri-state | done β€” `MeasureBound(kind="signed")`, `.unbounded`; `is_net_name` for a leading NET |
301
+ | A3 dedupe | done β€” `quality.findings.dedupe` (cross-stage, post-load wins; same-stage distinct messages kept) |
302
+ | A4 severity / roll-up / order | done β€” `info`; `slack_notifier.unclassified_rollup_line`; `_priority` tiers; unclassified alone is no post |
303
+ | A5 vocabulary + scores + prompt | done β€” Wave 2 commit; `measure_score_range_missing` rule; prompt 3c |
304
+ | B1 insight_weak message | done β€” realized / needed / promised (authored) / direction |
305
+ | B2 boost calibration | done β€” promise capped to the band, `drive_boost`, stall detection, `insight_promise_capped` |
306
+ | B3 windows + `insight_unmeasurable` | done β€” `DateSpec.span()/min_window_start()/default_window_start()`, four window rules, `ensure_insight_support` |
307
+ | C1 open ratios | done β€” veto list, floor-only bound, `pct`β†’`float`, semantic probe skipped |
308
+ | C2 pile-up text + share_of inference | done β€” cause-matched suggestion; margin-shape inference; `[BOUND-NOTE]` reasons |
309
+ | D identity formulas | done β€” `validate` + `alias_target`; mirror never recomputed; cause-named text |
310
+ | E1 pre-flight | done β€” ISS-22 precheck (cherry-pick) |
311
+ | E2 real auth error | done β€” `trusted_auth_failure_message` everywhere the error travels |
312
+ | E3 `deploy_existing_schema` | done in code, **unverified live** (mocked tests only); DDL retained on run records from now on |
313
+ | E4 provision the two SEs | ops, open |
314
+ | F hygiene | strip done; width clip stays at 100 until the Supabase migration (sprint doc); display name 80; URL forms verified by test |
315
+
316
+ Verification still owed (Β§5): replay the four blueprints on test-mcp and count
317
+ warnings per build; a refused-at-login run through `deploy_existing_schema`.
docs/handoff_iss22_login_precheck_2026_10_14.md ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ISS-22 handoff: login precheck before an MCP build
2
+
3
+ *2026-10-14. Branch `fix/iss-22-login-precheck`, based on `hf-mcp-test/main` = `6a8d963` (what
4
+ test-mcp runs). **Not pushed anywhere and not deployed.** Nothing was run against either Space.*
5
+
6
+ ## The problem
7
+
8
+ Some users have no ThoughtSpot account on the target cluster. Patrick Case (run `2a1c7306af2a`),
9
+ Kris Lefebvre (`b58980440961`) and James Ward (`1a69f7ab4c7f`) are examples. Clusters can also be
10
+ in standby: sebe was on 2026-10-06 (runs `b22397c2cfb3`, `c34abed7dddb`). In both cases the build
11
+ spent 2–4 minutes generating data and loading Snowflake, which left an orphan schema. It then
12
+ failed at login with "ThoughtSpot authentication failed". For standby, `session_logs` recorded only
13
+ `Expecting value: line 1 column 1 (char 0)`.
14
+
15
+ ## What changed
16
+
17
+ | File | Change |
18
+ |---|---|
19
+ | `mcp_server.py` | `_login_precheck()`: one trusted-auth token request for the owner, made after `_resolve_ts_target` / `_resolve_owner` and before the build slot. `_refuse_at_login_precheck()` writes the durable rows for a refused call. One call site in `build_demo_from_brief`. `_run_build`'s result row carries `login_precheck`. One docstring sentence. |
20
+ | `thoughtspot_errors.py` | Pure classification: `classify_trusted_login`, `ts_error_reason`, `is_standby_page`, `LOGIN_BLOCKING`, and the messages (`no_account_message`, `standby_message`, `rejected_login_message`, `non_json_auth_error`). |
21
+ | `thoughtspot_deployer.py` | `TRUSTED_TOKEN_PATH`, `TS_REQUEST_HEADERS` and `trusted_token_request_body()` are shared with the precheck, so it sends exactly the build's own login request. `authenticate_trusted()` now reports the real cause of a non-JSON 200 and does not retry it. `deploy_all` now raises `ThoughtSpot authentication failed for <user> on <url>: <cause>` instead of the bare message. |
22
+ | `supabase_client.py` | `log_mcp_result` stores `login_precheck`. That function lists its keys explicitly, so the new key had to be added there. |
23
+ | `tests/test_iss22_login_precheck.py` | New: 46 tests. Every branch uses mocked HTTP, and an autouse fixture fails any unmocked `requests.post`. |
24
+ | `tests/test_chained_portal_settings.py` | Stubs `_login_precheck`, so the existing tool-level test makes no network call. |
25
+ | `tests/test_portal_url_round_trip.py` | Two source-scan tests now read `_run_build`'s source, not the first `log_mcp_result({` in the module. Before the change, the refusal's call would have become that first match, and the tests would have checked the wrong block. |
26
+
27
+ ## Behaviour
28
+
29
+ The precheck sends `POST {ts_url}/api/rest/2.0/auth/token/full` with the same body and headers the
30
+ deployer uses. The username is normalised the same way the controller normalises it (lower-cased).
31
+ The timeout is `(3, 7)` seconds (connect, read), so the worst case is 10 s.
32
+
33
+ | ThoughtSpot answers | outcome | Build? | Caller gets |
34
+ |---|---|---|---|
35
+ | 200 with `token`, or 204 | `ok` | starts | `started` (unchanged) |
36
+ | 400/401/403, TS JSON error, "user does not exist" | `no_account` | **refused** | `failed`: `<email> has no ThoughtSpot account on <host>. Ask a ThoughtSpot admin to add them, or pass ts_url for a cluster where they have one.` |
37
+ | 400/401/403, TS JSON error, any other reason | `rejected` | **refused** | `failed`: `ThoughtSpot on <host> refused a trusted-auth login for <email> (HTTP <code>): <ThoughtSpot's reason>` |
38
+ | Non-JSON page containing "Start Instance" / "standby mode" | `standby` | **refused** | `failed`: `<host> is in standby. Open <url> in a browser and complete the wake-up check, then retry.` |
39
+ | Timeout, connection error, 429, 5xx | `transient` | starts | `started`; logged, and the deployer's own login retries |
40
+ | Anything else (200 with no token, a non-TS 4xx such as nginx 405, HTML 400) | `inconclusive` | starts | `started`; logged |
41
+
42
+ A refused call returns `{status: "failed", run_id, errors: [message], login_precheck}`. It takes no
43
+ build slot and generates nothing. It also writes the same `mcp_intake` and `mcp_result` rows
44
+ (`demo_history`) a build writes, joined by `run_id`, so "who tried to build without an account"
45
+ can still be answered from those rows.
46
+
47
+ **Recorded on every run.** `login_precheck = {outcome, blocking, http_status, reason, host,
48
+ elapsed_ms[, message]}` is stored on:
49
+
50
+ - the intake row, which takes it from the call payload;
51
+ - the result row, from both the refusal path and `_run_build`'s `result()`;
52
+ - the hit log, as `LOGIN_PRECHECK ...` and `REFUSED ...` lines.
53
+
54
+ The record never contains the key or the token.
55
+
56
+ **Deployer (App tab and MCP).** A 200 that is not JSON now reports one of:
57
+
58
+ - the standby message above;
59
+ - `ThoughtSpot returned a non-JSON response to the trusted-auth token request (HTTP 200, content-type X): '<first 200 chars>'`.
60
+
61
+ Both go to `last_auth_error`, which feeds `session_logs` "auth failed", and into the run's error.
62
+ For a missing account, the run error now quotes ThoughtSpot's own reason
63
+ (`Invalid parameter values: {"username":"Specified user does not exist."}`) instead of the raw JSON.
64
+
65
+ ## Evidence the shapes are real
66
+
67
+ - **Recorded failures.** `session_logs` "auth failed" rows exist for Kris Lefebvre and Patrick
68
+ Case (10-13), James Ward (09-28, secloud) and mike.boone (08-26, techpartners). All are
69
+ `HTTP 400`, `code 10002`, with debug text
70
+ `Invalid parameter values: {"username":"Specified user does not exist."}`.
71
+ - **Live check.** One read-only check per cluster on 2026-10-14, using the fake username
72
+ `iss22-precheck-no-such-user@example.invalid` (`scratch/iss22_fake_user_probe.py`). Secloud and
73
+ sebe both returned `400`, `application/json`, with the identical body. The verbatim body is
74
+ pinned in the tests. No other call was made to ThoughtSpot.
75
+ - **Why the match is on text, not the code.** Code `10002` is ThoughtSpot's generic "Invalid
76
+ parameter values". It also comes back for a GUID that is not on the cluster, so the
77
+ classifier matches "user does not exist" in the text instead.
78
+ - **Standby page.** The markers come from the issue text. No copy of the real page was saved: the
79
+ old code recorded only the parse error. The page title "ThoughtSpot – Start Instance" and the
80
+ body "standby mode" each trigger the match on their own.
81
+
82
+ ## Tests
83
+
84
+ Unit suite: `.venv` python, the agreed ignores, `-k "not e2e and not live and not replay"`. The main
85
+ checkout's `.env` was loaded via `scratch/iss22_run_unit_suite.py`.
86
+
87
+ | | result |
88
+ |---|---|
89
+ | Baseline `6a8d963` | **2 failed, 1130 passed**, 3 skipped, 28 deselected |
90
+ | This branch | **2 failed, 1176 passed**, 3 skipped, 28 deselected |
91
+
92
+ The 2 failures are the same tests both times, and both existed before this branch:
93
+ `test_iss12_flat_measures.py::test_no_pile_up_at_the_band_edges` and
94
+ `test_kpi_sparkline_comparison.py::test_every_converted_kpi_on_a_board_has_sparkline_state`.
95
+ The extra 46 passes are the new tests.
96
+
97
+ ## What is left / for boone
98
+
99
+ 1. **Not verified on a Space.** The next step is to push to test-mcp when boone says so. Then run
100
+ one build with a real account (expect `login_precheck.outcome = ok` on the intake row), and one
101
+ `build_demo_from_brief` with an `owner_email` that has no account (expect `failed` within
102
+ seconds and no schema).
103
+ 2. **Event-loop stall.** In mcp 1.28.1, FastMCP runs sync tools on the event loop. The precheck
104
+ therefore blocks other calls, including `status()` polls, for as long as it waits: usually one
105
+ fast request, at most 10 s. That is why the timeout is `(3, 7)`, not 10/10. Making the tool
106
+ async would remove the stall, but it is a bigger change in a file four agents are editing.
107
+ 3. **`rejected` blocks.** A ThoughtSpot JSON 400/401/403 for any reason other than a missing user
108
+ also blocks, because the build's login would fail the same way. If the cluster's trusted-auth key
109
+ were wrong, every build to that cluster would now fail in seconds, with ThoughtSpot's reason,
110
+ instead of minutes later. That outcome is intended, but it is worth knowing.
111
+ 4. **`inconclusive` does not block**, even though some of those cases (a 200 HTML that is not
112
+ standby, an nginx 405) will also fail the deployer's login. The precheck blocks only causes it
113
+ can name. One example: the 2026-09-02 Jack Rayner "auth failed" rows show a cluster URL
114
+ ending in `/#` (`.../thoughtspot.cloud/#`) and an nginx 405 in reply. That is a separate
115
+ URL-normalisation bug and is not fixed here. (`_resolve_ts_target` strips a trailing `/` but
116
+ not `/#`; I did not check which path those runs took.)
117
+ 5. **No Slack post for a refused call.** The SE sees the reason immediately in AgentSpot, and the
118
+ `demo_history` rows record it. Say if refusals should also post to the deployment channel.
119
+ 6. **The App tab is unchanged** apart from the clearer deployer error. App builds could use the
120
+ same precheck at GO time.
121
+ 7. **Sprint doc not updated.** `sprint_2026_04.md` is shared with parallel agents, so it was left
122
+ alone; add the ISS-22 line when merging.
mcp_server.py CHANGED
@@ -211,6 +211,96 @@ def _resolve_ts_target(ts_url: str) -> tuple[str, str]:
211
  f"TS_ENV_<n>_LABEL/URL/KEY_VAR triplet for this instance."
212
  )
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  from mcp.server.fastmcp import FastMCP
215
  from mcp.server.transport_security import TransportSecuritySettings
216
 
@@ -529,7 +619,9 @@ def status() -> dict:
529
  with _state_lock:
530
  active = [dict(b) for b in _active_builds.values()]
531
  last = dict(_last_build) if _last_build else None
532
- recent = [dict(r) for r in _recent_builds.values()]
 
 
533
  count = _build_count
534
  for b in active:
535
  b["elapsed_seconds"] = round(now - b.pop("started_at", now), 1)
@@ -582,8 +674,13 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
582
  ts_target_url: str, ts_auth_key: str, owner: str,
583
  with_portal: bool = True, call_payload: dict | None = None,
584
  portal_options: dict | None = None,
585
- salesforce_opportunity_id: str = "") -> dict:
 
586
  """Drive the controller headlessly (runs in a BACKGROUND THREAD; the caller has
 
 
 
 
587
  already registered the build in _active_builds and holds a _build_sem slot (released when this
588
  returns). Mirrors tests/newvision_sample_runner.py but injects `brief` in place of
589
  the research phase. Returns a structured dict and never raises β€” failures come back
@@ -658,11 +755,14 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
658
  "ddl": out["ddl"],
659
  "finished_at": time.strftime("%H:%M:%S"),
660
  }
661
- # Per-run result (compact, no ddl) so concurrent callers can collect EACH
662
- # build by run_id without racing the single _last_build slot.
 
 
663
  _recent_builds[run_id] = {
664
  "run_id": out["run_id"], "status": out["status"], "schema": out["schema"],
665
- "company_name": company_name,
 
666
  # The portal URL is the deliverable a caller shows a customer, so
667
  # it has to travel on EVERY channel that reports a run β€” not just
668
  # the return value. It was in `out` but in neither of these two
@@ -707,6 +807,10 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
707
  "findings_persisted": out["findings_persisted"],
708
  "findings_persist_error": out["findings_persist_error"],
709
  "elapsed_seconds": out["elapsed_seconds"],
 
 
 
 
710
  })
711
  _log_hit(f"RESULT_PERSISTED run_id={run_id} status={out['status']} supabase={_persisted_result}")
712
  return out
@@ -791,31 +895,44 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
791
  print(f"[MCP] run logger init failed (continuing without it): {_log_err}",
792
  file=sys.stderr)
793
 
794
- # (iv) DDL β€” returns a (response, ddl) tuple; NOT a generator.
795
- _set_progress(run_id, phase="building dataset + DDL", detail="")
796
- resp, ddl_text = controller.run_ddl_creation()
797
- if not ddl_text or "CREATE TABLE" not in ddl_text.upper():
798
- return result("failed", error=f"DDL generation failed: {str(resp)[:500]}")
799
- # Surface the DDL immediately β€” it exists ~5 min in, well before the ~15-min
800
- # TS deploy β€” so a caller polling status() gets the schema as soon as it's ready.
801
- with _state_lock:
802
- b = _active_builds.get(run_id)
803
- if b is not None:
804
- b["ddl"] = ddl_text
805
-
806
- # (v) Snowflake load, then ThoughtSpot. Both are generators β€” draining them
807
- # IS what runs the work. Decoupled from validation_mode: drain the Snowflake
808
- # generator, read the schema it set, then run the TS deploy ourselves.
809
- _set_progress(run_id, phase="loading Snowflake", detail="")
810
- for _item in controller.run_deployment_streaming():
811
- _apply_progress(run_id, _item)
812
- schema = getattr(controller, "_deployed_schema_name", None)
813
- if not schema:
814
- return result(
815
- "failed",
816
- schema=getattr(controller, "_last_schema_name", None),
817
- error="Snowflake load did not complete (no deployed schema).",
818
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
819
 
820
  _set_progress(run_id, phase="deploying ThoughtSpot model + liveboard", detail="")
821
  # Real-time status: on_progress fires from the deploy thread for EVERY
@@ -1009,7 +1126,9 @@ def build_demo_from_brief(
1009
  live customer-facing site built from the liveboard, and download_url,
1010
  a zip of its editable source for changing what the interview cannot
1011
  express (both empty when with_portal=False). A portal failure never
1012
- fails the build; it lands in warnings.
 
 
1013
  """
1014
  _log_hit(
1015
  f"CALL build_demo_from_brief company={company_name!r} use_case={use_case!r} "
@@ -1040,6 +1159,13 @@ def build_demo_from_brief(
1040
  except RuntimeError as e:
1041
  return {"status": "failed", "errors": [str(e)]}
1042
 
 
 
 
 
 
 
 
1043
  # Bounded concurrency: grab a build slot (non-blocking). Only when ALL slots are
1044
  # in use does a call get 'busy' β€” otherwise it runs alongside the others.
1045
  if not _build_sem.acquire(blocking=False):
@@ -1085,6 +1211,167 @@ def build_demo_from_brief(
1085
  }
1086
 
1087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1088
  # --------------------------------------------------------------------- portal
1089
 
1090
  _portal_sem = threading.Semaphore(2) # portal builds are cheap; bound them anyway
 
211
  f"TS_ENV_<n>_LABEL/URL/KEY_VAR triplet for this instance."
212
  )
213
 
214
+
215
+ # (connect, read) seconds β€” 10 s worst case. Short on purpose: FastMCP runs this
216
+ # sync tool on the server's event loop, so the wait stalls status() polls. A
217
+ # precheck that gets no answer in time lets the build go ahead (the deployer's
218
+ # own login has retries), so a tight limit costs nothing but a missed early catch.
219
+ _LOGIN_PRECHECK_TIMEOUT = (3, 7)
220
+
221
+
222
+ def _login_precheck(ts_url: str, ts_auth_key: str, owner: str) -> dict:
223
+ """Can `owner` log in to `ts_url` at all? ISS-22.
224
+
225
+ ONE trusted-auth token request β€” the same one the build's own login sends β€”
226
+ made before a build takes a slot or generates anything. A user with no
227
+ account on the cluster, or a cluster in standby, used to cost 2-4 minutes of
228
+ data generation and a Snowflake load (an orphan schema) and then fail at
229
+ login as "ThoughtSpot authentication failed".
230
+
231
+ no_account / rejected / standby blocking; `message` says why and what to do
232
+ transient (timeout, 429, 5xx) not blocking; the deployer retries its login
233
+ ok / inconclusive not blocking
234
+
235
+ Returns the outcome record that rides on the run's intake and result rows.
236
+ """
237
+ import requests
238
+ from chat_interface import require_authenticated_email
239
+ from thoughtspot_deployer import (
240
+ TRUSTED_TOKEN_PATH, TS_REQUEST_HEADERS, trusted_token_request_body,
241
+ )
242
+ from thoughtspot_errors import (
243
+ LOGIN_BLOCKING, classify_trusted_login, no_account_message,
244
+ rejected_login_message, standby_message,
245
+ )
246
+
247
+ # The username the build's deployer will send (the controller normalises it).
248
+ username = require_authenticated_email(user_email=owner)
249
+ started = time.time()
250
+ http_status = None
251
+ try:
252
+ resp = requests.post(
253
+ f"{ts_url}{TRUSTED_TOKEN_PATH}",
254
+ json=trusted_token_request_body(username, ts_auth_key),
255
+ headers=TS_REQUEST_HEADERS,
256
+ timeout=_LOGIN_PRECHECK_TIMEOUT,
257
+ )
258
+ http_status = resp.status_code
259
+ outcome, reason = classify_trusted_login(
260
+ resp.status_code, resp.headers.get("content-type", ""), resp.text)
261
+ except requests.RequestException as e:
262
+ outcome, reason = "transient", f"{type(e).__name__}: {e}"
263
+
264
+ record = {
265
+ "outcome": outcome,
266
+ "blocking": outcome in LOGIN_BLOCKING,
267
+ "http_status": http_status,
268
+ "reason": reason[:300],
269
+ "host": _host_of(ts_url),
270
+ "elapsed_ms": round((time.time() - started) * 1000),
271
+ }
272
+ if outcome == "no_account":
273
+ record["message"] = no_account_message(owner, ts_url)
274
+ elif outcome == "standby":
275
+ record["message"] = standby_message(ts_url)
276
+ elif outcome == "rejected":
277
+ record["message"] = rejected_login_message(owner, ts_url, http_status, reason)
278
+ _log_hit(f"LOGIN_PRECHECK owner={owner!r} host={record['host']} outcome={outcome} "
279
+ f"http={http_status} {record['elapsed_ms']}ms reason={reason[:200]!r}")
280
+ return record
281
+
282
+
283
+ def _refuse_at_login_precheck(call_payload: dict, brief: str, company_name: str,
284
+ use_case: str, ts_url: str, owner: str) -> dict:
285
+ """Fail a call the login precheck refused, leaving the same durable intake +
286
+ result rows a build leaves β€” "who tried to build without a ThoughtSpot
287
+ account" was answered from those rows, and must stay answerable."""
288
+ precheck = call_payload["login_precheck"]
289
+ run_id = uuid.uuid4().hex[:12]
290
+ log_mcp_payload({**call_payload, "run_id": run_id, "resolved_ts_url": ts_url,
291
+ "resolved_owner_email": owner, "brief": brief})
292
+ log_mcp_result({
293
+ **deployment_identity(),
294
+ "run_id": run_id, "company_name": company_name, "use_case": use_case,
295
+ "owner_email": owner, "ts_url": ts_url, "status": "failed",
296
+ "errors": [precheck["message"]], "login_precheck": precheck,
297
+ "elapsed_seconds": round(precheck["elapsed_ms"] / 1000, 1),
298
+ })
299
+ _log_hit(f"REFUSED run_id={run_id} at login precheck: {precheck['message']}")
300
+ return {"status": "failed", "run_id": run_id, "errors": [precheck["message"]],
301
+ "login_precheck": precheck}
302
+
303
+
304
  from mcp.server.fastmcp import FastMCP
305
  from mcp.server.transport_security import TransportSecuritySettings
306
 
 
619
  with _state_lock:
620
  active = [dict(b) for b in _active_builds.values()]
621
  last = dict(_last_build) if _last_build else None
622
+ # Without the DDL: it is kept on the record for deploy_existing_schema,
623
+ # not for a status poll.
624
+ recent = [{k: v for k, v in r.items() if k != "ddl"} for r in _recent_builds.values()]
625
  count = _build_count
626
  for b in active:
627
  b["elapsed_seconds"] = round(now - b.pop("started_at", now), 1)
 
674
  ts_target_url: str, ts_auth_key: str, owner: str,
675
  with_portal: bool = True, call_payload: dict | None = None,
676
  portal_options: dict | None = None,
677
+ salesforce_opportunity_id: str = "",
678
+ existing_schema: str = "", existing_ddl: str = "") -> dict:
679
  """Drive the controller headlessly (runs in a BACKGROUND THREAD; the caller has
680
+ already registered the build in _active_builds and holds a _build_sem slot).
681
+ With `existing_schema` + `existing_ddl` (deploy_existing_schema) the dataset,
682
+ DDL and Snowflake steps are skipped and the run continues from the ThoughtSpot
683
+ deploy against that schema. Otherwise: the caller has
684
  already registered the build in _active_builds and holds a _build_sem slot (released when this
685
  returns). Mirrors tests/newvision_sample_runner.py but injects `brief` in place of
686
  the research phase. Returns a structured dict and never raises β€” failures come back
 
755
  "ddl": out["ddl"],
756
  "finished_at": time.strftime("%H:%M:%S"),
757
  }
758
+ # Per-run result so concurrent callers can collect EACH build by run_id
759
+ # without racing the single _last_build slot. Carries the DDL so a run
760
+ # that died at the ThoughtSpot step can be finished from its schema
761
+ # (deploy_existing_schema); status() leaves the DDL out of its copies.
762
  _recent_builds[run_id] = {
763
  "run_id": out["run_id"], "status": out["status"], "schema": out["schema"],
764
+ "company_name": company_name, "use_case": use_case, "company_url": company_url,
765
+ "ddl": out["ddl"],
766
  # The portal URL is the deliverable a caller shows a customer, so
767
  # it has to travel on EVERY channel that reports a run β€” not just
768
  # the return value. It was in `out` but in neither of these two
 
807
  "findings_persisted": out["findings_persisted"],
808
  "findings_persist_error": out["findings_persist_error"],
809
  "elapsed_seconds": out["elapsed_seconds"],
810
+ "login_precheck": (call_payload or {}).get("login_precheck"),
811
+ # Kept so deploy_existing_schema can finish this run later from its
812
+ # schema if ThoughtSpot refused it (E3).
813
+ "ddl": out["ddl"],
814
  })
815
  _log_hit(f"RESULT_PERSISTED run_id={run_id} status={out['status']} supabase={_persisted_result}")
816
  return out
 
895
  print(f"[MCP] run logger init failed (continuing without it): {_log_err}",
896
  file=sys.stderr)
897
 
898
+ if existing_schema:
899
+ # (iv-v) REDEPLOY: the schema is already loaded β€” a build that died at
900
+ # the ThoughtSpot step (no account on the cluster, a cluster in
901
+ # standby). Skip generation and the load, hand the controller the DDL
902
+ # it would have produced, and continue from the ThoughtSpot deploy
903
+ # (deploy_existing_schema; findings triage 2026-10-15, E3).
904
+ ddl_text = existing_ddl
905
+ controller.ddl_code = existing_ddl
906
+ controller.demo_builder.schema_generation_results = existing_ddl
907
+ controller._deployed_schema_name = existing_schema
908
+ schema = existing_schema
909
+ _log_hit(f"REDEPLOY run_id={run_id} schema={schema} (no generation, no load)")
910
+ else:
911
+ # (iv) DDL β€” returns a (response, ddl) tuple; NOT a generator.
912
+ _set_progress(run_id, phase="building dataset + DDL", detail="")
913
+ resp, ddl_text = controller.run_ddl_creation()
914
+ if not ddl_text or "CREATE TABLE" not in ddl_text.upper():
915
+ return result("failed", error=f"DDL generation failed: {str(resp)[:500]}")
916
+ # Surface the DDL immediately β€” it exists ~5 min in, well before the ~15-min
917
+ # TS deploy β€” so a caller polling status() gets the schema as soon as it's ready.
918
+ with _state_lock:
919
+ b = _active_builds.get(run_id)
920
+ if b is not None:
921
+ b["ddl"] = ddl_text
922
+
923
+ # (v) Snowflake load, then ThoughtSpot. Both are generators β€” draining them
924
+ # IS what runs the work. Decoupled from validation_mode: drain the Snowflake
925
+ # generator, read the schema it set, then run the TS deploy ourselves.
926
+ _set_progress(run_id, phase="loading Snowflake", detail="")
927
+ for _item in controller.run_deployment_streaming():
928
+ _apply_progress(run_id, _item)
929
+ schema = getattr(controller, "_deployed_schema_name", None)
930
+ if not schema:
931
+ return result(
932
+ "failed",
933
+ schema=getattr(controller, "_last_schema_name", None),
934
+ error="Snowflake load did not complete (no deployed schema).",
935
+ )
936
 
937
  _set_progress(run_id, phase="deploying ThoughtSpot model + liveboard", detail="")
938
  # Real-time status: on_progress fires from the deploy thread for EVERY
 
1126
  live customer-facing site built from the liveboard, and download_url,
1127
  a zip of its editable source for changing what the interview cannot
1128
  express (both empty when with_portal=False). A portal failure never
1129
+ fails the build; it lands in warnings. If owner_email cannot log in to
1130
+ the cluster (no account there, or the cluster is in standby) the call
1131
+ fails at once with the reason and nothing is built.
1132
  """
1133
  _log_hit(
1134
  f"CALL build_demo_from_brief company={company_name!r} use_case={use_case!r} "
 
1159
  except RuntimeError as e:
1160
  return {"status": "failed", "errors": [str(e)]}
1161
 
1162
+ # Can the owner log in there at all? One short request, before a slot is taken
1163
+ # or anything is generated (ISS-22). The outcome rides on the run's records.
1164
+ _payload["login_precheck"] = precheck = _login_precheck(ts_target_url, ts_auth_key, owner)
1165
+ if precheck["blocking"]:
1166
+ return _refuse_at_login_precheck(_payload, brief, company_name, use_case,
1167
+ ts_target_url, owner)
1168
+
1169
  # Bounded concurrency: grab a build slot (non-blocking). Only when ALL slots are
1170
  # in use does a call get 'busy' β€” otherwise it runs alongside the others.
1171
  if not _build_sem.acquire(blocking=False):
 
1211
  }
1212
 
1213
 
1214
+ # ------------------------------------------------------- redeploy a schema
1215
+
1216
+ def _run_record(run_id: str) -> tuple[dict, str]:
1217
+ """What is known about a past run: (record, source).
1218
+
1219
+ This server's memory first (_recent_builds / _last_build β€” everything but
1220
+ the brief), then the durable demo_history rows (fetch_mcp_run: the intake
1221
+ row carries the brief and the call, the result row the schema and, since
1222
+ 2026-10-20, the DDL). Source is "memory", "demo_history" or "" when the run
1223
+ is unknown. The brief is always taken from demo_history when it is there,
1224
+ since memory never holds it.
1225
+ """
1226
+ run_id = (run_id or "").strip()
1227
+ if not run_id:
1228
+ return {}, ""
1229
+ with _state_lock:
1230
+ rec = dict(_recent_builds.get(run_id) or {})
1231
+ if not rec and _last_build and _last_build.get("run_id") == run_id:
1232
+ rec = dict(_last_build)
1233
+ from supabase_client import fetch_mcp_run
1234
+
1235
+ rows = fetch_mcp_run(run_id) or {}
1236
+ intake, result = rows.get("intake") or {}, rows.get("result") or {}
1237
+ if rec:
1238
+ rec.setdefault("brief", intake.get("brief") or "")
1239
+ rec.setdefault("use_case", intake.get("use_case") or "")
1240
+ rec.setdefault("company_url", intake.get("company_url") or "")
1241
+ return rec, "memory"
1242
+ if not intake and not result:
1243
+ return {}, ""
1244
+ return {
1245
+ "run_id": run_id,
1246
+ "company_name": result.get("company_name") or intake.get("company_name") or "",
1247
+ "use_case": result.get("use_case") or intake.get("use_case") or "",
1248
+ "company_url": intake.get("company_url") or "",
1249
+ "owner_email": (result.get("owner_email") or intake.get("resolved_owner_email")
1250
+ or intake.get("owner_email") or ""),
1251
+ "ts_url": result.get("ts_url") or intake.get("resolved_ts_url") or intake.get("ts_url") or "",
1252
+ "schema": result.get("schema") or "",
1253
+ "ddl": result.get("ddl") or "",
1254
+ "brief": intake.get("brief") or "",
1255
+ }, "demo_history"
1256
+
1257
+
1258
+ @mcp.tool()
1259
+ def deploy_existing_schema(
1260
+ run_id: str = "",
1261
+ schema: str = "",
1262
+ owner_email: str = "",
1263
+ ts_url: str = "",
1264
+ company_name: str = "",
1265
+ use_case: str = "",
1266
+ company_url: str = "",
1267
+ ddl: str = "",
1268
+ brief: str = "",
1269
+ with_portal: bool = True,
1270
+ ) -> dict:
1271
+ """Finish a build whose data is ALREADY in Snowflake: deploy the ThoughtSpot
1272
+ model + liveboard (and the portal) for an existing schema, without
1273
+ regenerating anything.
1274
+
1275
+ For a build that died at the ThoughtSpot step β€” the owner had no account on
1276
+ the cluster, or the cluster was in standby β€” once the account exists, or
1277
+ with a different cluster. Lead Forensics and Rice (2026-10-13..15) were
1278
+ rebuilt from scratch three times for want of this.
1279
+
1280
+ Identify the run by `run_id`: schema, DDL, company, use case, brief,
1281
+ cluster and owner are recovered from its records (this server's memory
1282
+ first, then demo_history). Override what changed β€” usually `owner_email`
1283
+ (the SE whose account now exists) or `ts_url`. Without `run_id`, pass
1284
+ `schema`, `ddl` (the CREATE TABLE text the build returned) and
1285
+ `company_name` yourself; `brief` improves the liveboard's story.
1286
+
1287
+ Runs in the background like build_demo_from_brief (~3-6 minutes) and
1288
+ returns a NEW run_id at once; poll status() for the model URL, liveboard
1289
+ URL and portal_url. The login precheck runs first, so a user who still has
1290
+ no account is refused in seconds with the reason.
1291
+
1292
+ Returns:
1293
+ dict: {status: "started", run_id, redeploy_of_run_id, schema, message},
1294
+ or {status: "failed"|"busy", errors: [...]}.
1295
+ """
1296
+ _log_hit(f"CALL deploy_existing_schema run_id={run_id!r} schema={schema!r} "
1297
+ f"owner={owner_email!r} ts_url={ts_url!r}")
1298
+ _payload = _call_payload(_inspect.currentframe())
1299
+
1300
+ record, source = _run_record(run_id)
1301
+ if run_id and not record:
1302
+ return {"status": "failed", "errors": [
1303
+ f"run {run_id!r} is not known to this server or to demo_history β€” pass schema, "
1304
+ "ddl and company_name directly"]}
1305
+ schema = (schema or record.get("schema") or "").strip()
1306
+ ddl = (ddl or record.get("ddl") or "").strip()
1307
+ company_name = (company_name or record.get("company_name") or "").strip()
1308
+ use_case = (use_case or record.get("use_case") or "Custom Analytics").strip()
1309
+ company_url = (company_url or record.get("company_url") or "").strip()
1310
+ brief = (brief or record.get("brief") or "").strip()
1311
+ owner_email = (owner_email or record.get("owner_email") or "").strip()
1312
+ ts_url = (ts_url or record.get("ts_url") or "").strip()
1313
+
1314
+ missing = [name for name, value in (("schema", schema), ("ddl", ddl), ("company_name", company_name))
1315
+ if not value]
1316
+ if missing:
1317
+ why = f"cannot redeploy without {', '.join(missing)}"
1318
+ if run_id:
1319
+ why += (f" β€” run {run_id}'s records do not carry them (built before DDL was kept on "
1320
+ "the result row); pass them directly")
1321
+ return {"status": "failed", "errors": [why]}
1322
+ if "CREATE TABLE" not in ddl.upper():
1323
+ return {"status": "failed", "errors": ["ddl must be the CREATE TABLE text of the loaded schema"]}
1324
+
1325
+ try:
1326
+ ts_target_url, ts_auth_key = _resolve_ts_target(ts_url)
1327
+ owner = _resolve_owner(owner_email)
1328
+ except RuntimeError as e:
1329
+ return {"status": "failed", "errors": [str(e)]}
1330
+
1331
+ _payload.update({"redeploy_of_run_id": run_id, "resolved_from": source or "arguments"})
1332
+ # The same precheck a fresh build gets: the whole point of this tool is a
1333
+ # user who could not log in last time, so say at once whether they can now.
1334
+ _payload["login_precheck"] = precheck = _login_precheck(ts_target_url, ts_auth_key, owner)
1335
+ if precheck["blocking"]:
1336
+ return _refuse_at_login_precheck(_payload, brief, company_name, use_case,
1337
+ ts_target_url, owner)
1338
+
1339
+ if not _build_sem.acquire(blocking=False):
1340
+ return {
1341
+ "status": "busy",
1342
+ "errors": [f"All {_MAX_CONCURRENT_BUILDS} build slots are in use. Poll status(); retry when one frees."],
1343
+ }
1344
+
1345
+ new_run_id = uuid.uuid4().hex[:12]
1346
+ global _build_count
1347
+ with _state_lock:
1348
+ _build_count += 1
1349
+ _active_builds[new_run_id] = {
1350
+ "run_id": new_run_id, "company_name": company_name, "use_case": use_case,
1351
+ "ts_url": ts_target_url, "schema": schema, "redeploy_of_run_id": run_id,
1352
+ "started_at": time.time(), "phase": "starting (redeploy of an existing schema)",
1353
+ }
1354
+
1355
+ def _worker() -> None:
1356
+ try:
1357
+ _run_build(new_run_id, brief, company_name, use_case, company_url, ts_target_url,
1358
+ ts_auth_key, owner, with_portal, _payload,
1359
+ portal_options=_chained_portal_options(),
1360
+ existing_schema=schema, existing_ddl=ddl)
1361
+ finally:
1362
+ _build_sem.release()
1363
+
1364
+ threading.Thread(target=_worker, daemon=True, name=f"redeploy-{new_run_id}").start()
1365
+ return {
1366
+ "status": "started",
1367
+ "run_id": new_run_id,
1368
+ "redeploy_of_run_id": run_id,
1369
+ "schema": schema,
1370
+ "message": ("ThoughtSpot deploy started in the background for the existing schema "
1371
+ "(~3-6 min). Poll status() for the model URL, liveboard URL and portal_url."),
1372
+ }
1373
+
1374
+
1375
  # --------------------------------------------------------------------- portal
1376
 
1377
  _portal_sem = threading.Semaphore(2) # portal builds are cheap; bound them anyway
slack_notifier.py CHANGED
@@ -159,7 +159,66 @@ def notify_dataset_quality_failure(
159
  #: How many findings one Slack message spells out. The rest are counted and
160
  #: live in demo_findings; a 40-line message is one nobody reads.
161
  MAX_FINDINGS_IN_SLACK = 12
162
- _SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
 
165
  def format_build_findings(
@@ -177,13 +236,22 @@ def format_build_findings(
177
  ) -> str:
178
  """The Slack text for a build's findings: prospect Β· SE Β· severity Β· what we
179
  noticed Β· the board link. `findings` are quality.findings.Finding records;
180
- `info` records are left out (they are actions that worked). `persisted`
181
- says whether the same records reached demo_findings (None = not attempted);
182
- a failed write is stated in the post, never hidden. Returns "" when there
183
- is nothing worth a message. Pure, so it is testable without Slack."""
 
 
 
 
 
 
 
 
184
  visible = sorted(
185
- (f for f in findings if getattr(f, "severity", "") in ("critical", "warning")),
186
- key=lambda f: (_SEVERITY_ORDER.get(f.severity, 9), 0 if f.blocks_send else 1, f.code),
 
187
  )
188
  if not visible:
189
  return ""
@@ -218,6 +286,9 @@ def format_build_findings(
218
  lines.append(f"β€’ [{f.severity.upper()}] {f.code}{where}: {f.message}{fix}"[:600])
219
  if len(visible) > MAX_FINDINGS_IN_SLACK:
220
  lines.append(f"… and {len(visible) - MAX_FINDINGS_IN_SLACK} more (every finding is in demo_findings)")
 
 
 
221
  return "\n".join(lines)
222
 
223
 
 
159
  #: How many findings one Slack message spells out. The rest are counted and
160
  #: live in demo_findings; a 40-line message is one nobody reads.
161
  MAX_FINDINGS_IN_SLACK = 12
162
+ #: How many unclassified measure names the roll-up line spells out.
163
+ MAX_UNCLASSIFIED_NAMED = 15
164
+
165
+ #: The one code that is rolled up into a single line per build instead of
166
+ #: being listed: a measure no bound class matched. It is a vocabulary gap in
167
+ #: dataset/measure_names.py, not a defect in the build's data, and it produced
168
+ #: ~600 of the 662 warnings posted over 2026-10-12..15 β€” filling every visible
169
+ #: slot while the pile-ups, the mirrored column and the weak insights fell
170
+ #: under "… and N more". Severity `info` since 2026-10-20 (boone).
171
+ ROLLUP_CODE = "bound_not_inferable"
172
+
173
+ #: Post-load / generation data defects: listed right after anything that
174
+ #: blocks the send, before the weak insights and the blueprint blemishes.
175
+ _DATA_DEFECT_CODES = frozenset({
176
+ "measure_bound_pileup", "measure_mirrors_other_column", "repair_failed",
177
+ "measure_constant", "part_exceeds_whole", "flag_not_binary", "measure_clamped",
178
+ "measure_soft_violation", "measure_mostly_zero", "measure_defect",
179
+ })
180
+
181
+
182
+ def _priority(f) -> tuple:
183
+ """Sort key: what would stop the demo first, blueprint blemishes last.
184
+
185
+ Alphabetical-by-code inside a severity put `bound_not_inferable` in every
186
+ one of the 12 visible slots. Tiers: critical β†’ send-blocking β†’ data defect
187
+ (pile-up, mirror, repair that did not hold) β†’ weak insight β†’ the rest.
188
+ """
189
+ if f.severity == "critical":
190
+ tier = 0
191
+ elif f.blocks_send:
192
+ tier = 1
193
+ elif f.code in _DATA_DEFECT_CODES:
194
+ tier = 2
195
+ elif f.code in ("insight_weak", "insight_unmeasurable"):
196
+ tier = 3
197
+ else:
198
+ tier = 4
199
+ return (tier, f.code, f.target)
200
+
201
+
202
+ def unclassified_rollup_line(findings: Iterable) -> str:
203
+ """One line naming every measure no bound class matched, or "" when none.
204
+
205
+ Deduped on the measure: the blueprint stage and the post-load gate both
206
+ emit the record (one from the declared mean, one from the observed), and
207
+ the same measure must not be counted twice.
208
+ """
209
+ names = sorted({
210
+ (f.target or f.measure or "?")
211
+ for f in findings if getattr(f, "code", "") == ROLLUP_CODE
212
+ })
213
+ if not names:
214
+ return ""
215
+ shown = ", ".join(names[:MAX_UNCLASSIFIED_NAMED])
216
+ more = f" … +{len(names) - MAX_UNCLASSIFIED_NAMED} more" if len(names) > MAX_UNCLASSIFIED_NAMED else ""
217
+ return (
218
+ f"ℹ️ {len(names)} measure(s) unclassified β€” no bound class matched the name, so no range "
219
+ f"was checked (a vocabulary gap in dataset/measure_names.py, not a data defect; every "
220
+ f"record is in demo_findings): {shown}{more}"
221
+ )
222
 
223
 
224
  def format_build_findings(
 
236
  ) -> str:
237
  """The Slack text for a build's findings: prospect Β· SE Β· severity Β· what we
238
  noticed Β· the board link. `findings` are quality.findings.Finding records;
239
+ `info` records are left out (they are actions that worked), except that the
240
+ unclassified-measure records (ROLLUP_CODE, whatever their severity) become
241
+ ONE roll-up line placed last. Records are deduped on (code, table, measure)
242
+ across stages first, keeping the post-load one. The list is ordered by
243
+ _priority, not alphabetically. `persisted` says whether the same records
244
+ reached demo_findings (None = not attempted); a failed write is stated in
245
+ the post, never hidden. Returns "" when there is nothing worth a message β€”
246
+ unclassified measures alone are a registry note, not a post. Pure, so it is
247
+ testable without Slack."""
248
+ from demoprep_app.quality.findings import dedupe
249
+
250
+ records = dedupe(list(findings))
251
  visible = sorted(
252
+ (f for f in records
253
+ if getattr(f, "severity", "") in ("critical", "warning") and f.code != ROLLUP_CODE),
254
+ key=_priority,
255
  )
256
  if not visible:
257
  return ""
 
286
  lines.append(f"β€’ [{f.severity.upper()}] {f.code}{where}: {f.message}{fix}"[:600])
287
  if len(visible) > MAX_FINDINGS_IN_SLACK:
288
  lines.append(f"… and {len(visible) - MAX_FINDINGS_IN_SLACK} more (every finding is in demo_findings)")
289
+ rollup = unclassified_rollup_line(records)
290
+ if rollup:
291
+ lines.append(rollup)
292
  return "\n".join(lines)
293
 
294
 
sprint_2026_04.md CHANGED
@@ -607,6 +607,12 @@ implementation keeps:
607
 
608
  ### Sprint 4 (this sprint)
609
 
 
 
 
 
 
 
610
  - [x] **Test suite collection fix (Oct 14)** β€” `tests/test_dataset_first_builders.py` deleted. It imported `infer_scenario_type`, which `3993fe3` (Jul 23, single blueprint pipeline) removed along with the keyword router, so `pytest --noconftest tests/` had aborted at collection ever since. All 28 tests asserted template-era routing and table shapes (`scenario_type == "saas_sales"`, fixed table lists, `build is None` for custom prompts, `company_contract_*` metadata, `RetailSalesDatasetGenerator`) that the LLM-authored blueprint path never produces; `build_dataset_first_demo` is now only an alias for `build_demo`, which always calls the LLM, so none were runnable offline. Explicit-table directives are covered by `tests/test_mckesson_regression.py`. Also re-pointed `tests/test_blueprint_pipeline.py`'s `_boost_weak_insights` import from the `dataset_first` shim (which never exported it) to `build_demo`. Suite now collects clean on the test line: 1178 pass / 5 skipped / 3 pre-existing fails (`test_iss12_flat_measures::test_no_pile_up_at_the_band_edges`, `test_kpi_sparkline_comparison::test_every_converted_kpi_on_a_board_has_sparkline_state`, `test_liveboard_creation_context::...uses_model_columns_and_matrix_config` β€” all fail identically on untouched 6a8d963). The LLM-free smoke script `tests/test_blueprint_pipeline.py` now gets past its boost loop (was ImportError on this line) and exposes a pre-existing WEAK planted insight (`evening_delivery_niche` observed 0.47 vs 1.33 required; same back to 5069b0c) β€” follow-up. Branch `fix/test-suite-collection`, rebased onto 7ffe729 for the test push βœ…
611
  - [ ] **Sep 28 β€” Opp ID + ISS-14 (AgentQ integration).** (a) **Opp ID β€” done on the MCP side, live on test-mcp `a996568`** (Jack's `salesforce_opportunity_id` on `build_demo_from_brief`, merged by the release session; prod `baca424` does not have it). It reaches the log with no further code: `demo_history` mcp_intake `results.salesforce_opportunity_id` (`_call_payload` β†’ `log_mcp_payload` stores the whole call) and `session_logs` `run started` `meta.payload.form.salesforce_opportunity_id` (`controller._run_payload`). (b) **Snowflake quality report** = `DEMOPREP_REPORTING.REPORTING.DEMO_RUNS` (built Sep 28 by the reviewer session as a batch copy Supabase β†’ Snowflake; loader scripts `refresh_runs.py` + `to_snowflake.py` exist only in that session's scratchpad, not the repo). Column `SALESFORCE_OPPORTUNITY_ID VARCHAR` added Sep 28 (survives the loader's TRUNCATE). **Still to do for it to fill:** Supabase `demo_runs` column (SQL-editor DDL), `refresh_runs.py` run dict `'salesforce_opportunity_id': irf.get('salesforce_opportunity_id') or None`, `to_snowflake.py` SPEC `('salesforce_opportunity_id', V)`; then get both scripts into `scripts/`. (c) ISS-14 fixed on `fix/iss-14-portal-date-dimension` (off test tip `925af12`; NOT pushed). **Next:** push to test-mcp (boone), replay a Collegis/General Mills brief, confirm the portal builds, then Jack can point AgentQ at it.
612
 
 
607
 
608
  ### Sprint 4 (this sprint)
609
 
610
+ - [x] **Findings triage, Waves 1–3 (Oct 20) β€” on `feature/findings-triage`, cut from the test line `hf-mcp-test/main` c0c09a2; NOT pushed.** Plan: `docs/PLAN_findings_triage_2026_10_15.md` (copy of the parent session's `dev_notes/` original, status section appended to both) (47 SE builds Oct 12–15: ~600 of 662 Slack warnings were `bound_not_inferable`; four deploy failures were unprovisioned users reported as "authentication failed"). Four commits: ISS-22 login precheck cherry-picked (6747616 β†’ 30d7d85), Wave 1 c6a9e6a, Wave 2 1e97c45, Wave 3 (this entry's tip). Decisions (boone 2026-10-20): `bound_not_inferable` = `info` + one roll-up line; `_RATIO` keeps 0-1 with an open-ratio veto; never auto-create ThoughtSpot users.
611
+ - **Wave 1 β€” stop the noise, show the real failures.** The post-load gate now reads the blueprint's `fmt`/`signed`/`kind` per measure (`DatasetColumn` carries them, `quality_gate.measure_formats`, threaded from `chat_interface`; the MCP path runs the same controller), so an int count or a currency amount is non-negative, not "unclassified". `infer_bound` returns a `signed` `MeasureBound` (unbounded by design, no gap) for NET_/MARGIN/VARIANCE/FLOW names, declared-signed measures and difference formulas instead of the same `None` as "no class matched". `quality.findings.dedupe`: one record per (code, table, measure) across stages, post-load wins (CSAT_SCORE_SUM was posted twice per build). `bound_not_inferable` is `info` at both emitters; Slack posts one roll-up line last and orders findings critical β†’ send-blocking β†’ data defect β†’ insight_weak β†’ rest. `insight_weak` states realized / needed / promised (as authored β€” `PlantedInsight.authored_magnitude`) / direction; the old text printed the pass bar as "it promised". `formula.validate` reports a copy (`X`, `X*1`, `1*X`, `X/1`, `X+0`) before generation; the gate never recomputes a mirror (straight to omit) and names the cause. `deploy_all`'s auth error is `thoughtspot_errors.trusted_auth_failure_message`: a missing account is the plain instruction naming user + cluster; the same text reaches the run errors, the MCP result and the Slack "Failed" post.
612
+ - **Wave 2 β€” bounds correctness.** Vocabulary: CAD/AUD/… currencies, a count vocabulary (plural nouns + counted events), money nouns (COGS, GMV, AUM, COMMISSION, FARE, MSRP, BUDGET…), physical units, proportion words (EFFICIENCY, COMPLETENESS, AVAILABILITY, ACCURACY, RETENTION, CONVERSION, UPTIME, LOAD_FACTOR…). `NAMED_SCORE_SCALES` (SAT 400-1600, ACT 1-36, CSAT 1-5, CES 1-7, GPA 0-4/0-5); `is_score_column` excludes `_SUM`/`TOTAL_` shapes; `findings()` warns `measure_score_range_missing` and the author prompt requires low/high on a score (3c). Open ratios (COVERAGE, COMBINED, TURNOVER, LEVERAGE, TURNS, CURRENT, QUICK, LOSS…, BOOK_TO_BILL, LTV_CAC, DEBT_TO) get a floor and no ceiling, keep AVERAGE aggregation, skip the post-load [0,1] probe, and `harden()` turns a declared `pct` into `float` (Bruker's 99.8%-pinned PIPELINE_COVERAGE_RATIO). Windows: `DateSpec.span()` is THE history the engine generates; `findings()` warns on a window that is unreadable / after the data / leaves < β…“ history before it; `autorepair()` moves it to the start of the last third (the hard-coded 2025-06-01 is gone). `ensure_insight_support()` sizes a thin segment's `mix_weight` from `BlueprintEngine.rows_per_fact` before generation; the too-few-rows case is its own code `insight_unmeasurable`. Pile-up suggestions match the cause (derived ratio β†’ declare share_of).
613
+ - **Wave 3 β€” calibration, redeploy, hygiene.** B2: a promise on a BOUNDED measure is capped at what the band can deliver (`_deliverable_band_ratio`, `(low+0.9w)/(low+0.5w)` Γ— 0.9 margin) and `PlantedInsight.drive_boost` pushes the rows' position in the band instead of inflating the magnitude; `insight_promise_capped` records it; the tuning loop stops when a round realized the same ratios (`_tuning_stalled`). C2: a margin rate (`GROSS_MARGIN / NET_SALES` with `GROSS_MARGIN = NET_SALES - COGS`, or `(NET_SALES - COGS) / NET_SALES`) bounds COGS as a share of NET_SALES through the difference; `[BOUND-NOTE]` hardening lines say why a rate was left to the MIN/MAX wrap. E3: MCP tool `deploy_existing_schema(run_id | schema+ddl+company_name, owner_email, ts_url…)` finishes a build from its loaded schema β€” `_run_build(existing_schema=…, existing_ddl=…)` skips generation and the load; the DDL is now kept on `_recent_builds` and the `demo_history` mcp_result row (`results.ddl`); `supabase_client.fetch_mcp_run` recovers older runs' intake/result rows. **Not verified against a live cluster** β€” mocked tests only; first real use should be a replay of a build refused at login. F: `demo_history` text values stripped (`_history_text`); the clip stays at 100 because the columns are `varchar(100)` β€” **Supabase migration for boone**: `alter table demo_history alter column company_name type text, alter column use_case type text;` then raise `DEMO_HISTORY_TEXT_WIDTH`. `extract_company_name` clips at 80 not 30. Both pinboard URL forms (`/#/pinboard/`, `/#/insights/pinboard/`) already parse everywhere a GUID is read (test added).
614
+ - **Tests:** 1402 passed / 5 skipped / 3 failed β€” the 3 pre-existing test-line failures (`test_iss12_flat_measures::test_no_pile_up_at_the_band_edges`, `test_kpi_sparkline_comparison::...sparkline_state`, `test_liveboard_creation_context::...matrix_config`), plus the pre-existing collection error in `tests/test_blank_threshold.py` (`pymupdf` is not in `.venv`); baseline on c0c09a2 was 1178 passed (`pytest tests --noconftest --continue-on-collection-errors`, `.venv`). Extended: `test_measure_bounds.py`, `test_measure_formula.py`, `test_quality_gate.py`, `test_flat_measure_gate.py`, `test_build_abort_policy.py` (incl. the rule guard for 5 new warning rules), `test_gate_severity.py`, `test_iss22_login_precheck.py`, `test_tuning_round_findings.py`; new `test_deploy_existing_schema.py`.
615
+ - **Still open from the plan:** ops β€” provision patrick.case and kris.lefebvre on se-thoughtspot-cloud (admin task); the Supabase column widening above; replay the Turtle / Frontline / Verizon / Humpty-Dump-2 blueprints on test-mcp to confirm warnings per build ≀ 5 (plan Β§5); Virtuoso/sebe TML-export gap unchanged. Push to `hf-mcp-test` is boone's call.
616
  - [x] **Test suite collection fix (Oct 14)** β€” `tests/test_dataset_first_builders.py` deleted. It imported `infer_scenario_type`, which `3993fe3` (Jul 23, single blueprint pipeline) removed along with the keyword router, so `pytest --noconftest tests/` had aborted at collection ever since. All 28 tests asserted template-era routing and table shapes (`scenario_type == "saas_sales"`, fixed table lists, `build is None` for custom prompts, `company_contract_*` metadata, `RetailSalesDatasetGenerator`) that the LLM-authored blueprint path never produces; `build_dataset_first_demo` is now only an alias for `build_demo`, which always calls the LLM, so none were runnable offline. Explicit-table directives are covered by `tests/test_mckesson_regression.py`. Also re-pointed `tests/test_blueprint_pipeline.py`'s `_boost_weak_insights` import from the `dataset_first` shim (which never exported it) to `build_demo`. Suite now collects clean on the test line: 1178 pass / 5 skipped / 3 pre-existing fails (`test_iss12_flat_measures::test_no_pile_up_at_the_band_edges`, `test_kpi_sparkline_comparison::test_every_converted_kpi_on_a_board_has_sparkline_state`, `test_liveboard_creation_context::...uses_model_columns_and_matrix_config` β€” all fail identically on untouched 6a8d963). The LLM-free smoke script `tests/test_blueprint_pipeline.py` now gets past its boost loop (was ImportError on this line) and exposes a pre-existing WEAK planted insight (`evening_delivery_niche` observed 0.47 vs 1.33 required; same back to 5069b0c) β€” follow-up. Branch `fix/test-suite-collection`, rebased onto 7ffe729 for the test push βœ…
617
  - [ ] **Sep 28 β€” Opp ID + ISS-14 (AgentQ integration).** (a) **Opp ID β€” done on the MCP side, live on test-mcp `a996568`** (Jack's `salesforce_opportunity_id` on `build_demo_from_brief`, merged by the release session; prod `baca424` does not have it). It reaches the log with no further code: `demo_history` mcp_intake `results.salesforce_opportunity_id` (`_call_payload` β†’ `log_mcp_payload` stores the whole call) and `session_logs` `run started` `meta.payload.form.salesforce_opportunity_id` (`controller._run_payload`). (b) **Snowflake quality report** = `DEMOPREP_REPORTING.REPORTING.DEMO_RUNS` (built Sep 28 by the reviewer session as a batch copy Supabase β†’ Snowflake; loader scripts `refresh_runs.py` + `to_snowflake.py` exist only in that session's scratchpad, not the repo). Column `SALESFORCE_OPPORTUNITY_ID VARCHAR` added Sep 28 (survives the loader's TRUNCATE). **Still to do for it to fill:** Supabase `demo_runs` column (SQL-editor DDL), `refresh_runs.py` run dict `'salesforce_opportunity_id': irf.get('salesforce_opportunity_id') or None`, `to_snowflake.py` SPEC `('salesforce_opportunity_id', V)`; then get both scripts into `scripts/`. (c) ISS-14 fixed on `fix/iss-14-portal-date-dimension` (off test tip `925af12`; NOT pushed). **Next:** push to test-mcp (boone), replay a Collegis/General Mills brief, confirm the portal builds, then Jack can point AgentQ at it.
618
 
supabase_client.py CHANGED
@@ -769,6 +769,61 @@ def inject_admin_settings_to_env() -> bool:
769
  return injected > 0
770
 
771
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772
  def log_mcp_payload(payload: Dict[str, Any]) -> bool:
773
  """Durably persist a raw MCP intake payload (the full brief + metadata) so real
774
  research payloads survive container restarts / log rotation β€” i.e. so you can tell
@@ -805,9 +860,9 @@ def log_mcp_payload(payload: Dict[str, Any]) -> bool:
805
  row = {
806
  "user_email": "mcp-intake@system", # sentinel β€” keeps MCP rows out of real users' history
807
  # top-level columns are varchar(100) β€” long values go in `results` untruncated
808
- "company_name": (payload.get("company_name") or "")[:100],
809
  "industry": "",
810
- "use_case": (payload.get("use_case") or "")[:100],
811
  "demo_config": {"source": "mcp_intake"},
812
  "results": results,
813
  "status": "mcp_intake",
@@ -840,9 +895,9 @@ def log_mcp_result(payload: Dict[str, Any]) -> bool:
840
  return False
841
  row = {
842
  "user_email": "mcp-intake@system",
843
- "company_name": (payload.get("company_name") or "")[:100],
844
  "industry": "",
845
- "use_case": (payload.get("use_case") or "")[:100],
846
  "demo_config": {"source": "mcp_result"},
847
  "results": {
848
  "run_id": payload.get("run_id"),
@@ -883,6 +938,13 @@ def log_mcp_result(payload: Dict[str, Any]) -> bool:
883
  "findings_persist_error": payload.get("findings_persist_error") or "",
884
  "errors": payload.get("errors") or [],
885
  "elapsed_seconds": payload.get("elapsed_seconds"),
 
 
 
 
 
 
 
886
  "source": "mcp",
887
  },
888
  "status": "mcp_result",
@@ -996,9 +1058,9 @@ def log_mcp_portal_call(payload: Dict[str, Any]) -> bool:
996
  args = payload.get("args") or {}
997
  row = {
998
  "user_email": "mcp-intake@system",
999
- "company_name": (str(args.get("company_name") or ""))[:100],
1000
  "industry": "",
1001
- "use_case": (str(args.get("use_case") or ""))[:100],
1002
  "demo_config": {"source": "mcp_portal"},
1003
  "results": {
1004
  "run_id": args.get("run_id") or "",
 
769
  return injected > 0
770
 
771
 
772
+ #: Width of demo_history's top-level text columns (company_name, use_case):
773
+ #: varchar(100) in the Supabase schema. The full values always travel in the
774
+ #: `results` JSON untruncated; these columns are the index the reporting
775
+ #: queries group on, and "OMERS Administration Corporati" / "…with futu"
776
+ #: (findings triage 2026-10-15, F) is what 100 does to them. Widening is a
777
+ #: Supabase migration (boone):
778
+ #: alter table demo_history alter column company_name type text,
779
+ #: alter column use_case type text;
780
+ #: then raise this constant (or drop the clip). The clip stays until then,
781
+ #: because an oversize value fails the INSERT and loses the whole record.
782
+ DEMO_HISTORY_TEXT_WIDTH = 100
783
+
784
+
785
+ def _history_text(value: Any) -> str:
786
+ """A demo_history text column value: whitespace stripped (a trailing space
787
+ made "Humpty Dump Roll Off Services " a second prospect), then clipped to
788
+ the column's width."""
789
+ return str(value or "").strip()[:DEMO_HISTORY_TEXT_WIDTH]
790
+
791
+
792
+ def fetch_mcp_run(run_id: str) -> Optional[Dict[str, Any]]:
793
+ """The durable records of one MCP run: {"intake": {...}, "result": {...}}.
794
+
795
+ Each is the row's `results` JSON merged with its top-level company_name /
796
+ use_case, or {} when that row does not exist. None when Supabase is off or
797
+ the lookup fails β€” never raises (mcp_server.deploy_existing_schema reads
798
+ this to recover a run's schema, DDL, brief, owner and cluster).
799
+ """
800
+ try:
801
+ client = SupabaseSettings()
802
+ if not getattr(client, "enabled", False) or not (run_id or "").strip():
803
+ return None
804
+ resp = (
805
+ client.client.table("demo_history")
806
+ .select("status,company_name,use_case,results,created_at")
807
+ .eq("results->>run_id", run_id.strip())
808
+ .in_("status", ["mcp_intake", "mcp_result"])
809
+ .order("created_at", desc=True)
810
+ .execute()
811
+ )
812
+ out: Dict[str, Any] = {"intake": {}, "result": {}}
813
+ for row in getattr(resp, "data", None) or []:
814
+ key = "intake" if row.get("status") == "mcp_intake" else "result"
815
+ if out[key]:
816
+ continue # newest row of each kind wins
817
+ merged = dict(row.get("results") or {})
818
+ merged.setdefault("company_name", row.get("company_name") or "")
819
+ merged.setdefault("use_case", row.get("use_case") or "")
820
+ out[key] = merged
821
+ return out
822
+ except Exception as e:
823
+ print(f"⚠️ mcp run lookup failed for {run_id!r}: {e}", flush=True)
824
+ return None
825
+
826
+
827
  def log_mcp_payload(payload: Dict[str, Any]) -> bool:
828
  """Durably persist a raw MCP intake payload (the full brief + metadata) so real
829
  research payloads survive container restarts / log rotation β€” i.e. so you can tell
 
860
  row = {
861
  "user_email": "mcp-intake@system", # sentinel β€” keeps MCP rows out of real users' history
862
  # top-level columns are varchar(100) β€” long values go in `results` untruncated
863
+ "company_name": _history_text(payload.get("company_name")),
864
  "industry": "",
865
+ "use_case": _history_text(payload.get("use_case")),
866
  "demo_config": {"source": "mcp_intake"},
867
  "results": results,
868
  "status": "mcp_intake",
 
895
  return False
896
  row = {
897
  "user_email": "mcp-intake@system",
898
+ "company_name": _history_text(payload.get("company_name")),
899
  "industry": "",
900
+ "use_case": _history_text(payload.get("use_case")),
901
  "demo_config": {"source": "mcp_result"},
902
  "results": {
903
  "run_id": payload.get("run_id"),
 
938
  "findings_persist_error": payload.get("findings_persist_error") or "",
939
  "errors": payload.get("errors") or [],
940
  "elapsed_seconds": payload.get("elapsed_seconds"),
941
+ # The pre-build login check (mcp_server._login_precheck, ISS-22):
942
+ # outcome, HTTP status, reason. None for runs from before it existed.
943
+ "login_precheck": payload.get("login_precheck"),
944
+ # The CREATE TABLE text the build loaded, so a run that died at
945
+ # the ThoughtSpot step can be finished later from its schema
946
+ # (mcp_server.deploy_existing_schema) without regenerating.
947
+ "ddl": payload.get("ddl") or "",
948
  "source": "mcp",
949
  },
950
  "status": "mcp_result",
 
1058
  args = payload.get("args") or {}
1059
  row = {
1060
  "user_email": "mcp-intake@system",
1061
+ "company_name": _history_text(args.get("company_name")),
1062
  "industry": "",
1063
+ "use_case": _history_text(args.get("use_case")),
1064
  "demo_config": {"source": "mcp_portal"},
1065
  "results": {
1066
  "run_id": args.get("run_id") or "",
tests/test_build_abort_policy.py CHANGED
@@ -842,6 +842,10 @@ WARNING_RULES = frozenset({
842
  "measure_params_missing", "measure_formula_missing", "insights_too_few",
843
  "insight_window_missing", "insight_too_subtle", "insight_question_missing",
844
  "dashboard_questions_too_few",
 
 
 
 
845
  })
846
 
847
 
@@ -870,6 +874,7 @@ def every_rule_findings():
870
  {"name": "NOFORMULA", "fmt": "float", "kind": "derived", "formula": ""},
871
  {"name": "KINDBAD", "fmt": "float", "kind": "weird", "params": {"mean": 1}},
872
  {"name": "FMTBAD", "fmt": "zzz", "params": {"mean": 1}},
 
873
  ]},
874
  {"name": "RETURNS", "grain": "one row per return", "measures": [
875
  {"name": "REVENUE", "fmt": "currency", "params": {"mean": 5}}]}, # duplicate name
@@ -887,6 +892,15 @@ def every_rule_findings():
887
  "dimension_value": "x", "measure": "NOPE", "magnitude": 1.3, "spotter_question": "q"},
888
  {"id": "i3", "headline": "h", "insight_type": "trend_break", "dimension": "SHIFT",
889
  "dimension_value": "Day", "measure": "REVENUE", "magnitude": 1.3, "spotter_question": "q"},
 
 
 
 
 
 
 
 
 
890
  {"id": "i4", "headline": "h", "insight_type": "segment_outlier", "dimension": "REGION",
891
  "dimension_value": "West", "measure": "REVENUE", "magnitude": 1.3, "spotter_question": "q"},
892
  {"id": "i5", "headline": "h", "insight_type": "segment_outlier", "dimension": "SHIFT",
@@ -944,3 +958,426 @@ def test_the_test_covers_every_rule_the_source_can_emit():
944
  assert in_source <= covered, f"rules in blueprint.py with no policy line: {in_source - covered}"
945
  assert covered - {"user_directive_violated"} <= in_source, \
946
  f"policy lines with no rule in blueprint.py: {covered - in_source - {'user_directive_violated'}}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
842
  "measure_params_missing", "measure_formula_missing", "insights_too_few",
843
  "insight_window_missing", "insight_too_subtle", "insight_question_missing",
844
  "dashboard_questions_too_few",
845
+ # findings triage 2026-10-15, Wave 2: a score states its scale; a window
846
+ # lies inside the history and leaves a 'before'
847
+ "measure_score_range_missing", "insight_window_invalid", "insight_window_outside_span",
848
+ "insight_window_too_early", "insight_window_end_invalid",
849
  })
850
 
851
 
 
874
  {"name": "NOFORMULA", "fmt": "float", "kind": "derived", "formula": ""},
875
  {"name": "KINDBAD", "fmt": "float", "kind": "weird", "params": {"mean": 1}},
876
  {"name": "FMTBAD", "fmt": "zzz", "params": {"mean": 1}},
877
+ {"name": "TRUST_SCORE", "fmt": "float", "params": {"mean": 70}}, # no declared scale
878
  ]},
879
  {"name": "RETURNS", "grain": "one row per return", "measures": [
880
  {"name": "REVENUE", "fmt": "currency", "params": {"mean": 5}}]}, # duplicate name
 
892
  "dimension_value": "x", "measure": "NOPE", "magnitude": 1.3, "spotter_question": "q"},
893
  {"id": "i3", "headline": "h", "insight_type": "trend_break", "dimension": "SHIFT",
894
  "dimension_value": "Day", "measure": "REVENUE", "magnitude": 1.3, "spotter_question": "q"},
895
+ {"id": "i6", "headline": "h", "insight_type": "trend_break", "dimension": "SHIFT",
896
+ "dimension_value": "Day", "measure": "REVENUE", "magnitude": 1.3, "spotter_question": "q",
897
+ "window_start": "relative_minus_8_months"}, # unreadable
898
+ {"id": "i7", "headline": "h", "insight_type": "trend_break", "dimension": "SHIFT",
899
+ "dimension_value": "Day", "measure": "REVENUE", "magnitude": 1.3, "spotter_question": "q",
900
+ "window_start": "2031-01-01"}, # after the data
901
+ {"id": "i8", "headline": "h", "insight_type": "trend_break", "dimension": "SHIFT",
902
+ "dimension_value": "Day", "measure": "REVENUE", "magnitude": 1.3, "spotter_question": "q",
903
+ "window_start": "2000-01-01", "window_end": "1999-01-01"}, # too early; end before start
904
  {"id": "i4", "headline": "h", "insight_type": "segment_outlier", "dimension": "REGION",
905
  "dimension_value": "West", "measure": "REVENUE", "magnitude": 1.3, "spotter_question": "q"},
906
  {"id": "i5", "headline": "h", "insight_type": "segment_outlier", "dimension": "SHIFT",
 
958
  assert in_source <= covered, f"rules in blueprint.py with no policy line: {in_source - covered}"
959
  assert covered - {"user_directive_violated"} <= in_source, \
960
  f"policy lines with no rule in blueprint.py: {covered - in_source - {'user_directive_violated'}}"
961
+
962
+
963
+ # --------------------------------------------------------------------------- #
964
+ # T Β· findings triage 2026-10-15, Wave 1 β€” one record per measure, the real
965
+ # failures first, the registry noise rolled up last, messages that read
966
+ # the right way round, and an auth failure that says what to do.
967
+ # --------------------------------------------------------------------------- #
968
+ def _unclassified(measure: str, stage: str, table: str = "GRID_ENERGY", observed: str = "") -> Finding:
969
+ return Finding(code="bound_not_inferable", severity="info", stage=stage,
970
+ message=f"no bound class matched {measure}", suggestion="add its vocabulary",
971
+ measure=measure, table=table, observed=observed)
972
+
973
+
974
+ def test_findings_dedupe_keeps_the_post_load_record_for_a_measure_flagged_twice():
975
+ """CSAT_SCORE_SUM was posted twice per build: blueprint (declared mean) and
976
+ post-load (observed mean), with different text."""
977
+ from demoprep_app.quality.findings import dedupe
978
+
979
+ blueprint = _unclassified("CSAT_SCORE_SUM", "blueprint")
980
+ post_load = _unclassified("CSAT_SCORE_SUM", "post_load", observed="min=1 max=5 avg=3.2")
981
+ weak1 = Finding(code="insight_weak", severity="warning", stage="validation",
982
+ message="planted insight i1 ...", suggestion="raise it")
983
+ weak2 = Finding(code="insight_weak", severity="warning", stage="validation",
984
+ message="planted insight i2 ...", suggestion="raise it")
985
+
986
+ out = dedupe([blueprint, weak1, post_load, weak2, weak1])
987
+ assert out == [post_load, weak1, weak2] # post-load wins, in the first slot; insights stay two
988
+ assert build_demo_module._dedupe([blueprint, post_load]) == [post_load]
989
+ assert dedupe([post_load, blueprint]) == [post_load] # whichever order they arrive
990
+
991
+
992
+ def test_slack_lists_what_matters_first_and_rolls_up_the_unclassified_last():
993
+ findings = [
994
+ _unclassified("SESSIONS", "post_load"),
995
+ _unclassified("COGS", "blueprint"), _unclassified("COGS", "post_load"),
996
+ Finding(code="insight_weak", severity="warning", stage="validation",
997
+ message="planted insight i1 realized 0.83x baseline", suggestion="raise magnitude"),
998
+ Finding(code=RULE_DIMENSION_CARDINALITY, severity="warning", stage="blueprint",
999
+ message="Dimension SHIFT: has only 2 values.", suggestion="fine if it really has 2",
1000
+ table="SHIFT", extra={"rule": RULE_DIMENSION_CARDINALITY}),
1001
+ Finding(code="measure_bound_pileup", severity="warning", stage="generation",
1002
+ message="218 rows on the cap", suggestion="lower the band", measure="FILL_RATE", table="LOADS"),
1003
+ Finding(code="repair_failed", severity="warning", stage="post_load",
1004
+ message="1 repair ran and the re-profile still violates", suggestion="fix the inputs",
1005
+ measure="PIPELINE_COVERAGE_RATIO", table="DEALS", repair="repair_failed"),
1006
+ ]
1007
+ text = format_build_findings(prospect="Turtle", se_email="se@x.com", findings=findings)
1008
+
1009
+ assert text.startswith("*DemoPrep build shipped with 4 warning(s)*"), text # unclassified are not warnings
1010
+ positions = [text.index(code) for code in
1011
+ ("repair_failed", "measure_bound_pileup", "insight_weak", "dimension_cardinality")]
1012
+ assert positions == sorted(positions), "send-blocking, data defect, weak insight, blemish β€” in that order"
1013
+ lines = text.splitlines()
1014
+ assert lines[-1].startswith("ℹ️ 2 measure(s) unclassified"), lines[-1] # COGS once, SESSIONS once
1015
+ assert "GRID_ENERGY.COGS, GRID_ENERGY.SESSIONS" in lines[-1]
1016
+ assert "bound_not_inferable" not in "\n".join(lines[:-1])
1017
+
1018
+
1019
+ def test_unclassified_measures_alone_are_not_a_slack_post():
1020
+ assert format_build_findings(prospect="X", se_email="y",
1021
+ findings=[_unclassified("SESSIONS", "post_load")]) == ""
1022
+
1023
+
1024
+ def test_the_rollup_comes_after_the_and_n_more_line():
1025
+ many = [Finding(code="measure_clamped", severity="warning", stage="generation",
1026
+ message=f"clamped {i}", suggestion="widen the band", measure=f"M{i}", table="T")
1027
+ for i in range(15)]
1028
+ text = format_build_findings(prospect="X", se_email="y",
1029
+ findings=many + [_unclassified("SESSIONS", "post_load")])
1030
+ lines = text.splitlines()
1031
+ assert lines[-2].startswith("… and 3 more")
1032
+ assert lines[-1].startswith("ℹ️ 1 measure(s) unclassified")
1033
+
1034
+
1035
+ def test_the_weak_insight_message_states_promised_needed_realized_and_direction():
1036
+ """It used to print the pass bar as "the N it promised": for a laggard,
1037
+ "realized 0.83 of the 0.69 it promised" meant needed <= 0.69 (promised
1038
+ 0.38), got 0.83 β€” and read as a false positive."""
1039
+ from demoprep_app.dataset.validator import InsightCheck, ValidationReport, _record_findings
1040
+
1041
+ report = ValidationReport()
1042
+ report.insight_checks.append(InsightCheck(
1043
+ "i3", False, 0.83, 0.69, "Prepaid vs rest of SEGMENT",
1044
+ promised_ratio=0.38, direction="below baseline",
1045
+ ))
1046
+ _record_findings(report)
1047
+ weak = [f for f in report.findings if f.code == "insight_weak"]
1048
+ assert len(weak) == 1
1049
+ msg = weak[0].message
1050
+ assert "realized 0.83x" in msg and "needed <= 0.69x" in msg
1051
+ assert "promised 0.38x" in msg and "below baseline" in msg
1052
+ assert "it promised" not in msg
1053
+ assert weak[0].expected.startswith("<= 0.69x") and weak[0].extra["promised_ratio"] == 0.38
1054
+
1055
+
1056
+ def test_the_tuning_boost_keeps_the_authored_promise_for_the_report():
1057
+ import types
1058
+
1059
+ from demoprep_app.dataset.engine import BlueprintEngine
1060
+ from demoprep_app.dataset.validator import validate_bundle
1061
+
1062
+ bp = _bp()
1063
+ insight = bp.insights[0]
1064
+ assert insight.authored_magnitude is None and insight.magnitude == 1.3
1065
+ report = types.SimpleNamespace(failed_insights=lambda: [types.SimpleNamespace(insight_id=insight.id)])
1066
+ build_demo_module._boost_weak_insights(bp, report)
1067
+ build_demo_module._boost_weak_insights(bp, report)
1068
+ assert insight.authored_magnitude == 1.3 # what the blueprint said
1069
+ assert insight.magnitude == pytest.approx(1.768, abs=1e-3) # what tuning made of it
1070
+
1071
+ checks = validate_bundle(bp, BlueprintEngine().generate(bp, 600)).insight_checks
1072
+ boosted = next(c for c in checks if c.insight_id == insight.id)
1073
+ assert boosted.promised_ratio == 1.3 and boosted.direction == "above baseline"
1074
+ assert all(c.promised_ratio == 1.3 for c in checks)
1075
+
1076
+
1077
+ def test_a_missing_account_is_a_plain_instruction_naming_user_and_cluster():
1078
+ from thoughtspot_errors import trusted_auth_failure_message
1079
+
1080
+ body = '{"error": {"message": "Specified user does not exist"}}'
1081
+ msg = trusted_auth_failure_message(
1082
+ "patrick.case@thoughtspot.com", "https://se-thoughtspot-cloud.thoughtspot.cloud", 400, body)
1083
+ assert msg.startswith(
1084
+ "patrick.case@thoughtspot.com has no ThoughtSpot account on se-thoughtspot-cloud.thoughtspot.cloud.")
1085
+ assert "Ask a ThoughtSpot admin" in msg
1086
+ assert msg.endswith("(ThoughtSpot authentication failed, HTTP 400: Specified user does not exist)")
1087
+
1088
+ other = trusted_auth_failure_message(
1089
+ "x@y.com", "https://h.thoughtspot.cloud", 401, '{"error": {"message": "Bad secret key"}}')
1090
+ assert other == ("ThoughtSpot authentication failed for x@y.com on https://h.thoughtspot.cloud "
1091
+ "(HTTP 401): Bad secret key")
1092
+
1093
+ net = trusted_auth_failure_message("x@y.com", "https://h.thoughtspot.cloud", None, "ConnectionError: boom")
1094
+ assert net == "ThoughtSpot authentication failed for x@y.com on https://h.thoughtspot.cloud: ConnectionError: boom"
1095
+
1096
+
1097
+ # --------------------------------------------------------------------------- #
1098
+ # U Β· findings triage 2026-10-15, Wave 2 β€” windows inside the history, segments
1099
+ # big enough to measure, scores that state their scale, the author prompt.
1100
+ # --------------------------------------------------------------------------- #
1101
+ from datetime import date as _date, timedelta as _timedelta # noqa: E402
1102
+
1103
+
1104
+ def _trend_insight(**overrides) -> dict:
1105
+ base = {"id": "t1", "headline": "h", "insight_type": "trend_break", "dimension": "SHIFT",
1106
+ "dimension_value": "Day", "measure": "UNITS_PICKED", "magnitude": 1.4,
1107
+ "spotter_question": "q", "expected_finding": "x"}
1108
+ base.update(overrides)
1109
+ return base
1110
+
1111
+
1112
+ def test_the_date_span_is_the_engines_and_the_default_window_opens_its_last_third():
1113
+ from demoprep_app.dataset.engine import _date_spine
1114
+ from demoprep_app.scenario.blueprint import DateSpec, WINDOW_MIN_HISTORY_BEFORE
1115
+
1116
+ spec = DateSpec(grain="day", months_of_history=12)
1117
+ start, end = spec.span(today=_date(2026, 10, 20))
1118
+ assert end == _date(2026, 9, 30) and start == end - _timedelta(days=int(12 * 30.44))
1119
+ earliest = spec.min_window_start(today=_date(2026, 10, 20))
1120
+ assert (earliest - start).days == int((end - start).days * WINDOW_MIN_HISTORY_BEFORE)
1121
+ default = spec.default_window_start(today=_date(2026, 10, 20))
1122
+ assert default.day == 1 and earliest <= default <= end
1123
+ assert default != _date(2025, 6, 1), "the hard-coded date is gone"
1124
+
1125
+ bp = _bp()
1126
+ dates = _date_spine(bp)
1127
+ assert (dates[0], dates[-1]) == bp.date.span()
1128
+
1129
+
1130
+ def test_a_window_before_the_history_warns_and_autorepair_moves_it_into_the_last_third():
1131
+ """Verizon / Fortegra / Turtle: 1946 target / 0 baseline β€” the window
1132
+ started at or before the first generated date, so there was no 'before'."""
1133
+ bp = _bp(insights=[_trend_insight(window_start="2025-06-01")])
1134
+ start, end = bp.date.span()
1135
+ assert _date(2025, 6, 1) < bp.date.min_window_start(), "test premise: too early with 12 months of history"
1136
+
1137
+ rules = {f.extra["rule"] for f in bp.findings()}
1138
+ assert "insight_window_too_early" in rules
1139
+ assert not any(f.severity == "critical" and "window" in f.message for f in bp.findings())
1140
+
1141
+ fixes = bp.autorepair()
1142
+ moved = bp.insights[0]
1143
+ assert moved.window_start == bp.date.default_window_start().isoformat()
1144
+ assert any("too early" in fix and moved.window_start in fix for fix in fixes)
1145
+ assert "insight_window_too_early" not in {f.extra["rule"] for f in bp.findings()}
1146
+
1147
+
1148
+ def test_a_missing_or_unreadable_or_late_window_lands_in_the_last_third_too():
1149
+ bp = _bp(insights=[
1150
+ _trend_insight(id="missing"),
1151
+ _trend_insight(id="unreadable", window_start="relative_minus_8_months"),
1152
+ _trend_insight(id="late", window_start="2031-01-01"),
1153
+ _trend_insight(id="backwards", window_start="2000-01-01", window_end="1999-01-01"),
1154
+ ])
1155
+ rules = {f.extra["rule"] for f in bp.findings()}
1156
+ assert {"insight_window_missing", "insight_window_invalid", "insight_window_outside_span",
1157
+ "insight_window_too_early", "insight_window_end_invalid"} <= rules
1158
+ bp.autorepair()
1159
+ default = bp.date.default_window_start().isoformat()
1160
+ assert all(i.window_start == default for i in bp.insights)
1161
+ assert all(i.window_end == "" for i in bp.insights)
1162
+
1163
+
1164
+ def test_a_segment_insight_with_a_window_outside_the_history_falls_back_to_the_whole_history():
1165
+ """AMD's Instinct MI300X, BCD's LHR -> JFK: 0 target / 0 baseline β€” a
1166
+ segment window outside the span drops every row on both sides."""
1167
+ bp = _bp(insights=[_trend_insight(id="seg", insight_type="segment_outlier",
1168
+ window_start="2031-01-01", window_end="2031-06-30")])
1169
+ assert "insight_window_outside_span" in {f.extra["rule"] for f in bp.findings()}
1170
+ bp.autorepair()
1171
+ assert (bp.insights[0].window_start, bp.insights[0].window_end) == ("", "")
1172
+
1173
+
1174
+ def test_insight_support_sizes_a_thin_segment_before_generation():
1175
+ """Humpty Dump's driver logs: one driver in eight on a small fact never
1176
+ reached 20 rows on its side, and the tuning loop found out after a full
1177
+ generation, three times."""
1178
+ from demoprep_app.dataset.engine import BlueprintEngine
1179
+ from demoprep_app.dataset.validator import MIN_ROWS_PER_SIDE, validate_bundle
1180
+
1181
+ drivers = [f"Driver {n}" for n in range(1, 9)]
1182
+ bp = _bp(dimensions=[{"name": "DRIVER", "values": [
1183
+ {"name": d, "mix_weight": 0.25 if d == "Driver 8" else 1.0} for d in drivers]},
1184
+ _dim("SITE", ["Wilmington", "Reno", "Atlanta"])],
1185
+ insights=[_trend_insight(id="thin", insight_type="segment_laggard", dimension="DRIVER",
1186
+ dimension_value="Driver 8", magnitude=0.7)])
1187
+ thin = next(v for v in bp.dimensions[0].values if v.name == "Driver 8")
1188
+ rows = BlueprintEngine.rows_per_fact(bp, 500)
1189
+ assert rows * 0.25 / 7.25 < MIN_ROWS_PER_SIDE, "test premise: ~17 rows is too thin"
1190
+
1191
+ fixes = bp.ensure_insight_support(rows)
1192
+ assert len(fixes) == 1 and "Driver 8" in fixes[0] and "mix_weight 0.25 ->" in fixes[0]
1193
+ assert thin.mix_weight > 0.25
1194
+ assert bp.metadata["insight_support"] == fixes
1195
+ # idempotent, and a healthy segment is left alone
1196
+ assert bp.ensure_insight_support(rows) == []
1197
+
1198
+ report = validate_bundle(bp, BlueprintEngine().generate(bp, 500))
1199
+ check = next(c for c in report.insight_checks if c.insight_id == "thin")
1200
+ assert check.measurable, check.detail
1201
+
1202
+
1203
+ def test_an_unmeasurable_insight_is_its_own_code_and_is_not_boosted():
1204
+ import types
1205
+
1206
+ from demoprep_app.dataset.validator import InsightCheck, ValidationReport, _record_findings
1207
+
1208
+ report = ValidationReport()
1209
+ report.insight_checks.append(InsightCheck(
1210
+ "i9", False, 0.0, 1.15, "too few rows to demo (4 target / 51 baseline; each side needs 20)",
1211
+ promised_ratio=1.3, direction="above baseline", measurable=False,
1212
+ ))
1213
+ _record_findings(report)
1214
+ assert [f.code for f in report.findings] == ["insight_unmeasurable"]
1215
+ finding = report.findings[0]
1216
+ assert "could not be measured" in finding.message and "4 target / 51 baseline" in finding.message
1217
+ assert "mix_weight" in finding.suggestion and finding.blocks_send is False
1218
+ assert "UNMEASURABLE" in report.summary()
1219
+
1220
+ bp = _bp()
1221
+ insight = bp.insights[0]
1222
+ unmeasurable = types.SimpleNamespace(insight_id=insight.id, measurable=False)
1223
+ build_demo_module._boost_weak_insights(bp, types.SimpleNamespace(failed_insights=lambda: [unmeasurable]))
1224
+ assert insight.magnitude == 1.3 and insight.authored_magnitude is None # magnitude untouched
1225
+ target = next(v for v in bp.dimensions[0].values if v.name == insight.dimension_value)
1226
+ assert target.mix_weight == 1.5 # the segment was widened
1227
+
1228
+
1229
+ def test_a_score_without_a_declared_scale_is_a_warning_to_the_author():
1230
+ bp = _bp(facts=[{
1231
+ "name": "ASSET_HEALTH", "grain": "one row per asset per day", "dimension_names": ["SHIFT", "SITE"],
1232
+ "measures": [
1233
+ {"name": "HEALTH_SCORE", "fmt": "float", "params": {"mean": 85, "sigma": 0.3}},
1234
+ {"name": "NPS_SCORE", "fmt": "float", "params": {"mean": 40}}, # scale by definition
1235
+ {"name": "RISK_INDEX", "fmt": "float", "params": {"low": 0, "high": 100}}, # declared
1236
+ {"name": "UNITS_PICKED", "fmt": "int", "params": {"mean": 4000, "sigma": 0.3}},
1237
+ ],
1238
+ }])
1239
+ scored = [f for f in bp.findings() if f.extra["rule"] == "measure_score_range_missing"]
1240
+ assert [f.measure for f in scored] == ["HEALTH_SCORE"]
1241
+ assert scored[0].severity == "warning" and "params.low and params.high" in scored[0].message
1242
+ assert "mean of 85" in scored[0].message
1243
+
1244
+
1245
+ def test_the_author_prompt_asks_for_score_scales_and_windows_inside_the_history():
1246
+ from demoprep_app.scenario.blueprint_author import PROMPT_TEMPLATE
1247
+
1248
+ assert "MUST declare its scale as params.low and params.high" in PROMPT_TEMPLATE
1249
+ assert "legitimately exceeds 1" in PROMPT_TEMPLATE
1250
+ assert "leave at least the first third of it" in PROMPT_TEMPLATE
1251
+ assert "at least 20 on each side" in PROMPT_TEMPLATE
1252
+
1253
+
1254
+ # --------------------------------------------------------------------------- #
1255
+ # V Β· findings triage 2026-10-15, Wave 3 β€” a promise capped at what the band
1256
+ # can deliver, rows pushed in the band instead; hygiene.
1257
+ # --------------------------------------------------------------------------- #
1258
+ def test_a_bounded_measures_deliverable_lift_comes_from_its_band():
1259
+ from demoprep_app.pipeline.build_demo import BAND_DELIVERY_MARGIN, _deliverable_band_ratio
1260
+ from demoprep_app.dataset.engine import POSITION_SWING
1261
+
1262
+ bp = _bp(facts=[{
1263
+ "name": "OPS", "grain": "one row per site per day", "dimension_names": ["SHIFT", "SITE"],
1264
+ "measures": [
1265
+ {"name": "UNITS_PICKED", "fmt": "int", "params": {"mean": 4000, "sigma": 0.3}},
1266
+ {"name": "UPTIME_RATE", "fmt": "pct", "params": {"low": 0.6, "high": 0.95}},
1267
+ {"name": "UNITS_ORDERED", "fmt": "int", "params": {"mean": 5000, "sigma": 0.3}},
1268
+ {"name": "UNITS_FILLED", "fmt": "int", "share_of": "UNITS_ORDERED", "params": {"low": 0.5, "high": 0.9}},
1269
+ {"name": "FILL_RATE", "kind": "derived", "fmt": "pct", "formula": "UNITS_FILLED / UNITS_ORDERED"},
1270
+ ],
1271
+ }], insights=[_trend_insight(id="lift", insight_type="segment_outlier", measure="UPTIME_RATE", magnitude=1.6)])
1272
+
1273
+ def lift(measure):
1274
+ return _deliverable_band_ratio(bp, _insight_on(measure))
1275
+
1276
+ assert lift("UNITS_PICKED") is None # a level: unbounded
1277
+ low, high = next(m for m in bp.facts[0].measures if m.name == "UPTIME_RATE").band
1278
+ width, centre = high - low, low + 0.5 * (high - low)
1279
+ expected_cap = round(1 + ((low + (0.5 + POSITION_SWING) * width) / centre - 1) * BAND_DELIVERY_MARGIN, 4)
1280
+ floor, cap = lift("UPTIME_RATE")
1281
+ assert cap == expected_cap and 0.5 < floor < 1 < cap < 1.6
1282
+ assert lift("UNITS_FILLED") == lift("FILL_RATE") # the ratio IS the fraction
1283
+
1284
+
1285
+ def _insight_on(measure):
1286
+ import types
1287
+ return types.SimpleNamespace(measure=measure)
1288
+
1289
+
1290
+ def test_an_unreachable_promise_is_capped_and_the_band_position_pushed_instead():
1291
+ import types
1292
+
1293
+ from demoprep_app.pipeline.build_demo import BOOST_FACTOR, _deliverable_band_ratio
1294
+
1295
+ bp = _bp(facts=[{
1296
+ "name": "OPS", "grain": "g", "dimension_names": ["SHIFT", "SITE"],
1297
+ "measures": [
1298
+ {"name": "UNITS_PICKED", "fmt": "int", "params": {"mean": 4000, "sigma": 0.3}},
1299
+ # a 0.30..0.95 band delivers ~1.37x at most: 1.6x is out of reach, 1.2x is not
1300
+ {"name": "UPTIME_RATE", "fmt": "pct", "params": {"low": 0.3, "high": 0.95}},
1301
+ ],
1302
+ }], insights=[
1303
+ _trend_insight(id="big", insight_type="segment_outlier", measure="UPTIME_RATE", magnitude=1.6),
1304
+ _trend_insight(id="fits", insight_type="segment_outlier", measure="UPTIME_RATE", magnitude=1.2),
1305
+ _trend_insight(id="level", insight_type="segment_outlier", measure="UNITS_PICKED", magnitude=1.3),
1306
+ ])
1307
+ _, cap = _deliverable_band_ratio(bp, bp.insights[0])
1308
+ assert cap < 1.6 and cap > 1.2, "test premise: 1.6x is out of reach, 1.2x is not"
1309
+ failed = [types.SimpleNamespace(insight_id=i.id, measurable=True) for i in bp.insights]
1310
+ notes = build_demo_module._boost_weak_insights(bp, types.SimpleNamespace(failed_insights=lambda: failed))
1311
+
1312
+ big, fits, level = bp.insights
1313
+ assert big.magnitude == round(cap, 3) and big.authored_magnitude == 1.6 # capped, promise kept
1314
+ assert big.drive_boost == BOOST_FACTOR
1315
+ assert fits.magnitude == 1.2 and fits.drive_boost == BOOST_FACTOR # reachable: push, no cap
1316
+ assert level.magnitude == pytest.approx(1.48) and level.drive_boost == 1.0 # unbounded: magnitude
1317
+ assert len(notes) == 1 and "big promised 1.6x" in notes[0] and f"capped at {cap:.2f}x" in notes[0]
1318
+ # a second round pushes further but never caps again
1319
+ assert build_demo_module._boost_weak_insights(bp, types.SimpleNamespace(failed_insights=lambda: failed)) == []
1320
+ assert big.drive_boost == pytest.approx(BOOST_FACTOR ** 2)
1321
+
1322
+
1323
+ def test_more_drive_moves_a_bounded_measure_further_without_touching_the_promise():
1324
+ from demoprep_app.dataset.engine import BlueprintEngine, _insight_drive
1325
+
1326
+ def ratio(drive_boost):
1327
+ bp = _bp(facts=[{
1328
+ "name": "OPS", "grain": "g", "dimension_names": ["SHIFT", "SITE"],
1329
+ "measures": [{"name": "UPTIME_RATE", "fmt": "pct", "params": {"low": 0.6, "high": 0.95}}],
1330
+ }], insights=[_trend_insight(id="u", insight_type="segment_outlier", measure="UPTIME_RATE",
1331
+ magnitude=1.2)])
1332
+ bp.insights[0].drive_boost = drive_boost
1333
+ rows = BlueprintEngine().generate(bp, 3000).table_map()["OPS"].rows
1334
+ day = [r["UPTIME_RATE"] for r in rows if r["SHIFT_KEY"] == 1]
1335
+ rest = [r["UPTIME_RATE"] for r in rows if r["SHIFT_KEY"] != 1]
1336
+ return (sum(day) / len(day)) / (sum(rest) / len(rest))
1337
+
1338
+ assert ratio(2.5) > ratio(1.0) > 1.0
1339
+ laggard = _bp().insights[0]
1340
+ laggard.magnitude, laggard.drive_boost = 0.7, 2.0
1341
+ assert _insight_drive(laggard) == pytest.approx(0.35) # pushed DOWN for a laggard
1342
+
1343
+
1344
+ def test_tuning_stops_when_a_round_changed_nothing():
1345
+ from demoprep_app.pipeline.build_demo import _tuning_stalled
1346
+
1347
+ assert not _tuning_stalled({}, {"i1": 1.12})
1348
+ assert not _tuning_stalled({"i1": 1.12}, {"i1": 1.19})
1349
+ assert _tuning_stalled({"i1": 1.12, "i2": 0.9}, {"i1": 1.121, "i2": 0.9})
1350
+ assert not _tuning_stalled({"i1": 1.12}, {"i3": 1.12}) # nothing in common: not a stall
1351
+ src = inspect_source(build_demo_module.build_demo)
1352
+ assert "_tuning_stalled(previous_observed, observed_now)" in src
1353
+ assert 'code="insight_promise_capped"' in src
1354
+
1355
+
1356
+ def inspect_source(fn):
1357
+ import inspect
1358
+ return inspect.getsource(fn)
1359
+
1360
+
1361
+ def test_history_text_strips_and_clips_and_the_display_name_is_no_longer_cut_at_30():
1362
+ from supabase_client import DEMO_HISTORY_TEXT_WIDTH, _history_text
1363
+ from demo_builder_class import DemoBuilder
1364
+
1365
+ assert _history_text("Humpty Dump Roll Off Services ") == "Humpty Dump Roll Off Services"
1366
+ assert len(_history_text("x" * 300)) == DEMO_HISTORY_TEXT_WIDTH == 100
1367
+ assert _history_text(None) == ""
1368
+
1369
+ import types
1370
+ builder = DemoBuilder.__new__(DemoBuilder)
1371
+ builder.website_data = types.SimpleNamespace(title="OMERS Administration Corporation | Pensions")
1372
+ builder.company_url = "https://omers.com"
1373
+ assert builder.extract_company_name() == "OMERS Administration Corporation"
1374
+
1375
+
1376
+ def test_both_pinboard_url_forms_resolve_to_the_same_guid():
1377
+ from mcp_server import _guid_from_url, _liveboard_guid
1378
+
1379
+ guid = "3ac80964-1a2b-4c3d-8e9f-0a1b2c3d4e5f"
1380
+ old = f"https://x.thoughtspot.cloud/#/pinboard/{guid}"
1381
+ new = f"https://x.thoughtspot.cloud/#/insights/pinboard/{guid}" # ThoughtSpot UI since 2026-10-14
1382
+ assert _guid_from_url(old) == _guid_from_url(new) == guid
1383
+ assert _liveboard_guid(old) == _liveboard_guid(new) == guid
tests/test_chained_portal_settings.py CHANGED
@@ -112,6 +112,9 @@ def test_tool_hands_the_settings_to_the_build(monkeypatch):
112
  monkeypatch.setattr(mcp_server, "_run_build", fake_run_build)
113
  monkeypatch.setattr(mcp_server, "_resolve_ts_target", lambda url: ("https://ts.example", "k"))
114
  monkeypatch.setattr(mcp_server, "_resolve_owner", lambda e: "se@thoughtspot.com")
 
 
 
115
 
116
  out = mcp_server.build_demo_from_brief(
117
  brief="A brief.", company_name="Acme", owner_email="se@thoughtspot.com",
 
112
  monkeypatch.setattr(mcp_server, "_run_build", fake_run_build)
113
  monkeypatch.setattr(mcp_server, "_resolve_ts_target", lambda url: ("https://ts.example", "k"))
114
  monkeypatch.setattr(mcp_server, "_resolve_owner", lambda e: "se@thoughtspot.com")
115
+ # No network in unit tests: the ISS-22 login precheck is stubbed as passing.
116
+ monkeypatch.setattr(mcp_server, "_login_precheck",
117
+ lambda url, key, owner: {"outcome": "ok", "blocking": False})
118
 
119
  out = mcp_server.build_demo_from_brief(
120
  brief="A brief.", company_name="Acme", owner_email="se@thoughtspot.com",
tests/test_deploy_existing_schema.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """deploy_existing_schema: finish a build whose data is already in Snowflake.
2
+
3
+ Findings triage 2026-10-15, E3. Four builds in three days did the whole
4
+ generation and load, then failed at the ThoughtSpot step because the owner had
5
+ no account on the cluster; the SEs rebuilt from scratch (Lead Forensics three
6
+ times). This tool continues from the loaded schema: the run's records give the
7
+ schema, DDL, company, brief, cluster and owner; the caller overrides what
8
+ changed. Everything here is mocked β€” no Snowflake, no ThoughtSpot.
9
+
10
+ Run with: pytest tests/test_deploy_existing_schema.py -q --noconftest
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import inspect
16
+ import threading
17
+
18
+ import pytest
19
+
20
+ import mcp_server
21
+ import supabase_client
22
+
23
+ DDL = 'CREATE TABLE "LEADS" ("LEADS_KEY" NUMBER, "LEADS_CREATED" NUMBER(38,0));'
24
+ RUN = {
25
+ "run_id": "lead0001", "status": "partial", "schema": "LEA_10151433_N6I_sch",
26
+ "company_name": "Lead Forensics", "use_case": "Marketing Attribution",
27
+ "company_url": "https://leadforensics.com", "owner_email": "patrick.case@thoughtspot.com",
28
+ "ts_url": "https://se-thoughtspot-cloud.thoughtspot.cloud", "ddl": DDL,
29
+ "model_url": "", "liveboard_url": "", "warnings": [], "findings": [], "errors": ["auth"],
30
+ "findings_persisted": None, "findings_persist_error": "", "elapsed_seconds": 180.0,
31
+ "portal_url": "", "download_url": "", "finished_at": "14:11:00",
32
+ }
33
+
34
+
35
+ @pytest.fixture(autouse=True)
36
+ def _quiet_supabase(monkeypatch):
37
+ monkeypatch.setattr(supabase_client, "fetch_mcp_run", lambda run_id: None)
38
+ monkeypatch.setattr(mcp_server, "log_mcp_payload", lambda payload: False)
39
+ monkeypatch.setattr(mcp_server, "log_mcp_result", lambda payload: False)
40
+ mcp_server._recent_builds.clear()
41
+ yield
42
+ mcp_server._recent_builds.clear()
43
+
44
+
45
+ def _precheck_ok(*a, **k):
46
+ return {"outcome": "ok", "blocking": False, "http_status": 200, "reason": "", "host": "h", "elapsed_ms": 3}
47
+
48
+
49
+ # --- the run runner continues from the loaded schema -------------------------
50
+
51
+ def test_the_runner_skips_generation_and_the_load_for_an_existing_schema():
52
+ src = inspect.getsource(mcp_server._run_build)
53
+ assert "existing_schema: str = \"\", existing_ddl: str = \"\"" in src
54
+ assert "controller.demo_builder.schema_generation_results = existing_ddl" in src
55
+ assert "controller._deployed_schema_name = existing_schema" in src
56
+ assert "controller.ddl_code = existing_ddl" in src
57
+ # the generation + load steps only run on the normal path
58
+ assert src.index("if existing_schema:") < src.index("controller.run_ddl_creation()")
59
+ assert src.index("if existing_schema:") < src.index("controller.run_deployment_streaming()")
60
+
61
+
62
+ def test_the_ddl_is_kept_on_the_run_records_but_left_out_of_status():
63
+ src = inspect.getsource(mcp_server._run_build)
64
+ # _last_build (always had it) + _recent_builds + log_mcp_result
65
+ assert '"ddl": out["ddl"],' in src and src.count('"ddl": out["ddl"]') == 3
66
+ assert 'if k != "ddl"' in inspect.getsource(mcp_server.status)
67
+ sb = inspect.getsource(supabase_client.log_mcp_result)
68
+ assert '"ddl": payload.get("ddl") or ""' in sb
69
+
70
+
71
+ # --- resolving a past run ------------------------------------------------------
72
+
73
+ def test_a_run_is_recovered_from_memory_first_then_from_demo_history(monkeypatch):
74
+ mcp_server._recent_builds["lead0001"] = dict(RUN)
75
+ monkeypatch.setattr(supabase_client, "fetch_mcp_run",
76
+ lambda run_id: {"intake": {"brief": "B2B lead attribution"}, "result": {}})
77
+ record, source = mcp_server._run_record("lead0001")
78
+ assert source == "memory" and record["schema"] == RUN["schema"] and record["ddl"] == DDL
79
+ assert record["brief"] == "B2B lead attribution" # memory never holds the brief
80
+
81
+ mcp_server._recent_builds.clear()
82
+ rice = {
83
+ "intake": {"company_name": "Rice University", "use_case": "Admissions", "brief": "yield",
84
+ "resolved_owner_email": "kris.lefebvre@thoughtspot.com",
85
+ "resolved_ts_url": "https://se-thoughtspot-cloud.thoughtspot.cloud",
86
+ "company_url": "https://rice.edu"},
87
+ "result": {"schema": "RIC_10132058_S9S_sch", "ddl": DDL, "company_name": "Rice University"},
88
+ }
89
+ monkeypatch.setattr(supabase_client, "fetch_mcp_run",
90
+ lambda run_id: rice if run_id == "rice0001" else {"intake": {}, "result": {}})
91
+ record, source = mcp_server._run_record("rice0001")
92
+ assert source == "demo_history"
93
+ assert record["schema"] == "RIC_10132058_S9S_sch" and record["ddl"] == DDL
94
+ assert record["owner_email"] == "kris.lefebvre@thoughtspot.com" and record["brief"] == "yield"
95
+ assert mcp_server._run_record("nope") == ({}, "")
96
+
97
+
98
+ # --- the tool ------------------------------------------------------------------
99
+
100
+ def test_an_unknown_run_or_missing_pieces_fail_at_once():
101
+ out = mcp_server.deploy_existing_schema(run_id="nope")
102
+ assert out["status"] == "failed" and "not known" in out["errors"][0]
103
+ out = mcp_server.deploy_existing_schema(schema="X_sch", company_name="X")
104
+ assert out["status"] == "failed" and "without ddl" in out["errors"][0]
105
+ out = mcp_server.deploy_existing_schema(schema="X_sch", company_name="X", ddl="not ddl")
106
+ assert out["status"] == "failed" and "CREATE TABLE" in out["errors"][0]
107
+
108
+
109
+ def test_a_user_who_still_has_no_account_is_refused_before_a_slot_is_taken(monkeypatch):
110
+ mcp_server._recent_builds["lead0001"] = dict(RUN)
111
+ monkeypatch.setattr(mcp_server, "_resolve_ts_target", lambda url: (url, "key"))
112
+ monkeypatch.setattr(mcp_server, "_login_precheck", lambda url, key, owner: {
113
+ "outcome": "no_account", "blocking": True, "http_status": 400, "reason": "Specified user does not exist",
114
+ "host": "se-thoughtspot-cloud.thoughtspot.cloud", "elapsed_ms": 40,
115
+ "message": f"{owner} has no ThoughtSpot account on se-thoughtspot-cloud.thoughtspot.cloud.",
116
+ })
117
+ called = []
118
+ monkeypatch.setattr(mcp_server, "_run_build", lambda *a, **k: called.append(k))
119
+ active_before = set(mcp_server._active_builds) # other tests may leave builds registered
120
+ out = mcp_server.deploy_existing_schema(run_id="lead0001")
121
+ assert out["status"] == "failed"
122
+ assert out["errors"][0].startswith("patrick.case@thoughtspot.com has no ThoughtSpot account")
123
+ assert called == [] and set(mcp_server._active_builds) == active_before
124
+
125
+
126
+ def test_a_redeploy_runs_the_runner_against_the_existing_schema_with_overrides(monkeypatch):
127
+ mcp_server._recent_builds["lead0001"] = dict(RUN)
128
+ monkeypatch.setattr(mcp_server, "_resolve_ts_target", lambda url: (url, "key"))
129
+ monkeypatch.setattr(mcp_server, "_login_precheck", _precheck_ok)
130
+ done = threading.Event()
131
+ seen: dict = {}
132
+
133
+ def fake_run_build(*args, **kwargs):
134
+ seen["args"], seen["kwargs"] = args, kwargs
135
+ with mcp_server._state_lock:
136
+ mcp_server._active_builds.pop(args[0], None)
137
+ done.set()
138
+
139
+ monkeypatch.setattr(mcp_server, "_run_build", fake_run_build)
140
+ out = mcp_server.deploy_existing_schema(run_id="lead0001", owner_email="max.lough@thoughtspot.com")
141
+ assert out["status"] == "started" and out["redeploy_of_run_id"] == "lead0001"
142
+ assert out["run_id"] != "lead0001" and out["schema"] == RUN["schema"]
143
+ assert done.wait(5), "the runner thread never started"
144
+
145
+ run_id, brief, company, use_case, company_url, ts_url, key, owner, with_portal, payload = seen["args"]
146
+ assert run_id == out["run_id"] and company == "Lead Forensics" and use_case == "Marketing Attribution"
147
+ assert owner == "max.lough@thoughtspot.com" # the override
148
+ assert ts_url == RUN["ts_url"] and company_url == RUN["company_url"]
149
+ assert seen["kwargs"]["existing_schema"] == RUN["schema"] and seen["kwargs"]["existing_ddl"] == DDL
150
+ assert payload["redeploy_of_run_id"] == "lead0001" and payload["resolved_from"] == "memory"
151
+ assert payload["login_precheck"]["outcome"] == "ok"
152
+ # the slot is released once the runner returns
153
+ assert mcp_server._build_sem.acquire(blocking=False) and (mcp_server._build_sem.release() or True)
154
+
155
+
156
+ def test_the_tool_is_registered():
157
+ assert "deploy_existing_schema" in mcp_server._registered_tool_names()
tests/test_flat_measure_gate.py CHANGED
@@ -117,3 +117,33 @@ def test_gate_is_a_warning_not_a_blocker():
117
  def test_gate_is_quiet_on_healthy_data():
118
  report = validate_dataset_quality(_build())
119
  assert not [i for i in report.issues if "identical across every dimension" in i.message]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def test_gate_is_quiet_on_healthy_data():
118
  report = validate_dataset_quality(_build())
119
  assert not [i for i in report.issues if "identical across every dimension" in i.message]
120
+
121
+
122
+ # --- the bundle carries each measure's declared semantics (triage 2026-10-15, A1)
123
+
124
+ def test_the_bundle_carries_fmt_and_sign_so_the_gate_classifies_every_measure():
125
+ """The post-load gate used to infer bounds from the NAME alone and posted
126
+ every int count and currency amount as "no bound class matched". The
127
+ engine now stamps fmt / signed / kind on each measure column, and
128
+ measure_formats() hands them to the gate."""
129
+ from demoprep_app.integrations.snowflake.quality_gate import measure_formats, semantic_bound
130
+
131
+ bundle = _build()
132
+ fact = next(t for t in bundle.tables if t.is_fact)
133
+ formats = measure_formats(bundle)
134
+
135
+ assert formats[f"{fact.name}.CLAIMS_SUBMITTED"] == {"fmt": "int", "signed": False, "kind": "base"}
136
+ assert formats[f"{fact.name}.CHARGE_AMOUNT"]["fmt"] == "currency"
137
+ assert formats[f"{fact.name}.COLLECTION_RATE"]["kind"] == "derived"
138
+ assert not any(key.endswith("_KEY") for key in formats), "keys and dates carry no format"
139
+
140
+ for key, declared in formats.items():
141
+ table, _, col = key.partition(".")
142
+ if table != fact.name:
143
+ continue
144
+ column = next(c for c in fact.columns if c.name == col)
145
+ stats = {"avg": statistics.mean(float(r[col]) for r in fact.rows)}
146
+ bound = semantic_bound(
147
+ col, stats, column.formula or "", fmt=declared["fmt"], signed=declared["signed"],
148
+ )
149
+ assert bound is not None and not bound.gap, f"{col}: unclassified with its format known"
tests/test_gate_severity.py CHANGED
@@ -84,7 +84,10 @@ def test_uninferable_score_range_warns_with_the_declaration_to_add():
84
  assert findings, "an uninferable score range must be reported, not silently defaulted"
85
  finding = next(f for f in findings if f.measure == "READINESS_SCORE")
86
 
87
- assert finding.severity == "warning" # NOT critical: the build goes on
 
 
 
88
  assert finding.code == "bound_not_inferable"
89
  assert not blocking(findings) # nothing here aborts
90
  # It names the measure and the concrete declaration to add.
 
84
  assert findings, "an uninferable score range must be reported, not silently defaulted"
85
  finding = next(f for f in findings if f.measure == "READINESS_SCORE")
86
 
87
+ # `info` since 2026-10-20 (boone): a registry gap, kept for the record and
88
+ # rolled into one Slack line per build β€” never critical, never a warning
89
+ # per measure. The build goes on either way.
90
+ assert finding.severity == "info"
91
  assert finding.code == "bound_not_inferable"
92
  assert not blocking(findings) # nothing here aborts
93
  # It names the measure and the concrete declaration to add.
tests/test_iss22_login_precheck.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ISS-22: a build whose owner cannot log in must fail in seconds, saying why.
2
+
3
+ Patrick Case, Kris Lefebvre and James Ward have no ThoughtSpot account on the
4
+ cluster their builds targeted; on 2026-10-06 sebe was in standby. Each build
5
+ still spent 2-4 minutes generating data and loading Snowflake (an orphan
6
+ schema) and then failed at login as "ThoughtSpot authentication failed" β€” for
7
+ standby, behind a JSON parse error.
8
+
9
+ build_demo_from_brief now makes ONE trusted-auth token request before it takes
10
+ a slot or generates anything. These tests drive every branch with mocked HTTP:
11
+ nothing here touches the network.
12
+ """
13
+ import collections
14
+ import json
15
+ import os
16
+ import threading
17
+
18
+ import pytest
19
+ import requests
20
+
21
+ os.environ.setdefault("MCP_TRANSPORT", "stdio")
22
+
23
+ import thoughtspot_deployer
24
+ from thoughtspot_errors import (
25
+ LOGIN_BLOCKING,
26
+ classify_trusted_login,
27
+ no_account_message,
28
+ non_json_auth_error,
29
+ standby_message,
30
+ ts_error_reason,
31
+ )
32
+
33
+ SECLOUD = "https://se-thoughtspot-cloud.thoughtspot.cloud"
34
+ SEBE = "https://sebe.thoughtspotstaging.cloud"
35
+ OWNER = "patrick.case@thoughtspot.com"
36
+
37
+ # Verbatim: the body secloud and sebe returned for a fake username on
38
+ # 2026-10-14, and the body recorded for runs 2a1c7306af2a / b58980440961 /
39
+ # 1a69f7ab4c7f (incident ids differ per call).
40
+ NO_ACCOUNT_BODY = (
41
+ r'{"error":{"message":{"debug":{"code":10002,"incident_id_guid":'
42
+ r'"3f8389f9-7dd8-4e69-8b0f-879a07467a89","trace_id_guid":"tracing-disabled",'
43
+ r'"debug":"[\"Invalid parameter values: {\\\"username\\\":\\\"Specified user '
44
+ r'does not exist.\\\"}\"]"}}}}'
45
+ )
46
+ # The page sebe served for every API call while in standby (2026-10-06), HTTP 200.
47
+ STANDBY_PAGE = (
48
+ "<!DOCTYPE html><html><head><title>ThoughtSpot – Start Instance</title></head>"
49
+ "<body><h1>Your instance entered standby mode</h1><p>Complete the verification "
50
+ "below to wake up your instance.</p></body></html>"
51
+ )
52
+ BAD_KEY_BODY = json.dumps({"error": {"message": {"debug": {
53
+ "code": 10003, "debug": json.dumps(["Invalid secret key for trusted authentication."])}}}})
54
+
55
+
56
+ class FakeResponse:
57
+ """Just enough of requests.Response: json() raises on non-JSON like the real one."""
58
+
59
+ def __init__(self, status_code, text="", content_type="application/json"):
60
+ self.status_code = status_code
61
+ self.text = text
62
+ self.headers = {"content-type": content_type} if content_type else {}
63
+
64
+ def json(self):
65
+ return json.loads(self.text)
66
+
67
+
68
+ def test_the_verbatim_no_account_body_is_its_own_shape():
69
+ assert json.loads(NO_ACCOUNT_BODY)["error"]["message"]["debug"]["code"] == 10002
70
+ assert ts_error_reason(NO_ACCOUNT_BODY) == (
71
+ 'Invalid parameter values: {"username":"Specified user does not exist."}')
72
+
73
+
74
+ # --- classification: one response shape per outcome ------------------------ #
75
+ @pytest.mark.parametrize("status, text, ctype, outcome", [
76
+ (200, '{"token": "t-123", "valid_for_user_id": "u"}', "application/json", "ok"),
77
+ (204, "", "", "ok"),
78
+ (400, NO_ACCOUNT_BODY, "application/json", "no_account"),
79
+ (401, BAD_KEY_BODY, "application/json", "rejected"),
80
+ (403, BAD_KEY_BODY, "application/json", "rejected"),
81
+ (200, STANDBY_PAGE, "text/html", "standby"),
82
+ (429, "Too Many Requests", "text/plain", "transient"),
83
+ (500, "", "text/html", "transient"),
84
+ (502, "<html>Bad Gateway</html>", "text/html", "transient"),
85
+ (503, "<html>Service Unavailable</html>", "text/html", "transient"),
86
+ (504, "", "", "transient"),
87
+ # Not proof the login fails, so not a refusal:
88
+ (200, "<html><body>Sign in with Okta</body></html>", "text/html", "inconclusive"),
89
+ (200, '{"unexpected": true}', "application/json", "inconclusive"),
90
+ (405, "<html><head><title>405 Not Allowed</title></head></html>", "text/html", "inconclusive"),
91
+ (400, "<html>Bad Request</html>", "text/html", "inconclusive"),
92
+ (404, '{"error": "not found"}', "application/json", "inconclusive"),
93
+ ])
94
+ def test_classify_trusted_login(status, text, ctype, outcome):
95
+ got, _reason = classify_trusted_login(status, ctype, text)
96
+ assert got == outcome
97
+
98
+
99
+ def test_only_causes_the_build_would_also_die_of_are_blocking():
100
+ assert LOGIN_BLOCKING == {"no_account", "rejected", "standby"}
101
+
102
+
103
+ def test_messages_name_the_user_the_host_and_the_fix():
104
+ assert no_account_message(OWNER, SECLOUD) == (
105
+ "patrick.case@thoughtspot.com has no ThoughtSpot account on "
106
+ "se-thoughtspot-cloud.thoughtspot.cloud. Ask a ThoughtSpot admin to add them, "
107
+ "or pass ts_url for a cluster where they have one.")
108
+ assert standby_message(SEBE) == (
109
+ "sebe.thoughtspotstaging.cloud is in standby. Open "
110
+ "https://sebe.thoughtspotstaging.cloud in a browser and complete the wake-up "
111
+ "check, then retry.")
112
+
113
+
114
+ # --- the precheck itself, every branch, mocked HTTP ------------------------- #
115
+ @pytest.fixture(autouse=True)
116
+ def no_network(monkeypatch):
117
+ """Any request a test did not mock fails loudly instead of leaving the box."""
118
+ def refuse(url, **_kw):
119
+ raise AssertionError(f"real network call in a unit test: POST {url}")
120
+ monkeypatch.setattr(requests, "post", refuse)
121
+
122
+
123
+ @pytest.fixture
124
+ def mcp():
125
+ import mcp_server
126
+ return mcp_server
127
+
128
+
129
+ def _post_returning(monkeypatch, response=None, exc=None):
130
+ calls = []
131
+
132
+ def fake_post(url, **kwargs):
133
+ calls.append({"url": url, **kwargs})
134
+ if exc is not None:
135
+ raise exc
136
+ return response
137
+
138
+ monkeypatch.setattr(requests, "post", fake_post)
139
+ return calls
140
+
141
+
142
+ def test_precheck_sends_exactly_the_builds_own_login_request(mcp, monkeypatch):
143
+ calls = _post_returning(monkeypatch, FakeResponse(200, '{"token": "t"}'))
144
+ rec = mcp._login_precheck(SECLOUD, "the-key", OWNER)
145
+ assert rec["outcome"] == "ok" and rec["blocking"] is False
146
+ assert len(calls) == 1, "one request, no retries β€” latency budget is one short call"
147
+ call = calls[0]
148
+ assert call["url"] == SECLOUD + "/api/rest/2.0/auth/token/full"
149
+ assert call["json"] == thoughtspot_deployer.trusted_token_request_body(OWNER, "the-key")
150
+ assert call["headers"]["X-Requested-By"] == "ThoughtSpot"
151
+ connect, read = call["timeout"]
152
+ assert connect + read <= 10, "the precheck may cost one short request, no more"
153
+
154
+
155
+ def test_precheck_sends_the_username_the_deployer_will_send(mcp, monkeypatch):
156
+ """The controller lower-cases the owner before the deployer logs in; the
157
+ precheck must ask about that same username or it predicts nothing."""
158
+ calls = _post_returning(monkeypatch, FakeResponse(200, '{"token": "t"}'))
159
+ mcp._login_precheck(SECLOUD, "k", " Patrick.Case@ThoughtSpot.com ")
160
+ assert calls[0]["json"]["username"] == OWNER
161
+
162
+
163
+ def test_precheck_no_account_blocks_with_the_real_reason(mcp, monkeypatch):
164
+ _post_returning(monkeypatch, FakeResponse(400, NO_ACCOUNT_BODY))
165
+ rec = mcp._login_precheck(SECLOUD, "k", OWNER)
166
+ assert rec["outcome"] == "no_account" and rec["blocking"] is True
167
+ assert rec["http_status"] == 400
168
+ assert rec["message"] == no_account_message(OWNER, SECLOUD)
169
+ assert "Specified user does not exist" in rec["reason"]
170
+
171
+
172
+ def test_precheck_standby_blocks_with_the_wake_up_instruction(mcp, monkeypatch):
173
+ _post_returning(monkeypatch, FakeResponse(200, STANDBY_PAGE, "text/html; charset=utf-8"))
174
+ rec = mcp._login_precheck(SEBE, "k", OWNER)
175
+ assert rec["outcome"] == "standby" and rec["blocking"] is True
176
+ assert rec["message"] == standby_message(SEBE)
177
+
178
+
179
+ def test_precheck_other_rejection_blocks_and_quotes_thoughtspot(mcp, monkeypatch):
180
+ _post_returning(monkeypatch, FakeResponse(401, BAD_KEY_BODY))
181
+ rec = mcp._login_precheck(SECLOUD, "k", OWNER)
182
+ assert rec["outcome"] == "rejected" and rec["blocking"] is True
183
+ assert "HTTP 401" in rec["message"]
184
+ assert "Invalid secret key for trusted authentication." in rec["message"]
185
+ assert "has no ThoughtSpot account" not in rec["message"], "must not guess the cause"
186
+
187
+
188
+ @pytest.mark.parametrize("exc", [
189
+ requests.exceptions.ReadTimeout("Read timed out. (read timeout=10)"),
190
+ requests.exceptions.ConnectTimeout("Connect timed out"),
191
+ requests.exceptions.ConnectionError("Connection reset by peer"),
192
+ ])
193
+ def test_precheck_network_trouble_does_not_block(mcp, monkeypatch, exc):
194
+ _post_returning(monkeypatch, exc=exc)
195
+ rec = mcp._login_precheck(SECLOUD, "k", OWNER)
196
+ assert rec["outcome"] == "transient" and rec["blocking"] is False
197
+ assert rec["http_status"] is None
198
+ assert type(exc).__name__ in rec["reason"]
199
+ assert "message" not in rec
200
+
201
+
202
+ @pytest.mark.parametrize("status", [429, 500, 502, 503, 504])
203
+ def test_precheck_throttling_and_server_errors_do_not_block(mcp, monkeypatch, status):
204
+ _post_returning(monkeypatch, FakeResponse(status, "busy", "text/plain"))
205
+ rec = mcp._login_precheck(SECLOUD, "k", OWNER)
206
+ assert rec["outcome"] == "transient" and rec["blocking"] is False
207
+ assert rec["http_status"] == status
208
+
209
+
210
+ def test_precheck_unrecognised_answer_does_not_block(mcp, monkeypatch):
211
+ _post_returning(monkeypatch, FakeResponse(200, "<html>Sign in</html>", "text/html"))
212
+ rec = mcp._login_precheck(SECLOUD, "k", OWNER)
213
+ assert rec["outcome"] == "inconclusive" and rec["blocking"] is False
214
+ assert "text/html" in rec["reason"]
215
+
216
+
217
+ def test_the_precheck_record_never_carries_the_key(mcp, monkeypatch):
218
+ _post_returning(monkeypatch, FakeResponse(400, NO_ACCOUNT_BODY))
219
+ rec = mcp._login_precheck(SECLOUD, "SECRET-KEY-VALUE-123", OWNER)
220
+ assert "SECRET-KEY-VALUE-123" not in json.dumps(rec)
221
+
222
+
223
+ # --- wired into build_demo_from_brief --------------------------------------- #
224
+ class _Sink:
225
+ """Captures the durable intake/result rows instead of writing Supabase."""
226
+
227
+ def __init__(self):
228
+ self.intake, self.result = [], []
229
+
230
+
231
+ @pytest.fixture
232
+ def tool(mcp, monkeypatch):
233
+ """build_demo_from_brief with the resolvers, sinks and build stubbed out."""
234
+ sink = _Sink()
235
+ started = threading.Event()
236
+ seen = {}
237
+
238
+ def fake_run_build(*args, **kwargs):
239
+ seen["args"], seen["kwargs"] = args, kwargs
240
+ started.set()
241
+ return {}
242
+
243
+ monkeypatch.setattr(mcp, "_resolve_ts_target", lambda url: (SECLOUD, "k"))
244
+ monkeypatch.setattr(mcp, "_resolve_owner", lambda e: OWNER)
245
+ monkeypatch.setattr(mcp, "log_mcp_payload", lambda p: sink.intake.append(p) or True)
246
+ monkeypatch.setattr(mcp, "log_mcp_result", lambda p: sink.result.append(p) or True)
247
+ monkeypatch.setattr(mcp, "_run_build", fake_run_build)
248
+ monkeypatch.setattr(mcp, "_active_builds", {}) # keep the module's live state clean
249
+
250
+ def call():
251
+ return mcp.build_demo_from_brief(
252
+ brief="Lead Forensics wants to see pipeline by source.",
253
+ company_name="Lead Forensics", owner_email=OWNER, with_portal=False)
254
+
255
+ return call, sink, started, seen
256
+
257
+
258
+ @pytest.mark.parametrize("response, message", [
259
+ (FakeResponse(400, NO_ACCOUNT_BODY), no_account_message(OWNER, SECLOUD)),
260
+ (FakeResponse(200, STANDBY_PAGE, "text/html"), standby_message(SECLOUD)),
261
+ ])
262
+ def test_a_refused_login_fails_the_call_before_any_build_work(tool, mcp, monkeypatch,
263
+ response, message):
264
+ call, sink, started, _seen = tool
265
+ _post_returning(monkeypatch, response)
266
+ slots_before = mcp._build_sem._value
267
+
268
+ out = call()
269
+
270
+ assert out["status"] == "failed"
271
+ assert out["errors"] == [message]
272
+ assert out["login_precheck"]["blocking"] is True
273
+ assert not started.wait(0.2), "the build must never start"
274
+ assert mcp._build_sem._value == slots_before, "no build slot may be taken"
275
+ # Durable: the refusal leaves the same intake + result rows a build does,
276
+ # joined by run_id, both carrying the precheck outcome.
277
+ assert len(sink.intake) == 1 and len(sink.result) == 1
278
+ intake, result = sink.intake[0], sink.result[0]
279
+ assert intake["run_id"] == result["run_id"] == out["run_id"]
280
+ assert intake["login_precheck"] == result["login_precheck"] == out["login_precheck"]
281
+ assert intake["brief"].startswith("Lead Forensics")
282
+ assert intake["resolved_owner_email"] == OWNER and intake["resolved_ts_url"] == SECLOUD
283
+ assert result["status"] == "failed" and result["errors"] == [message]
284
+ assert result["owner_email"] == OWNER and result["ts_url"] == SECLOUD
285
+
286
+
287
+ @pytest.mark.parametrize("response, exc, outcome", [
288
+ (FakeResponse(200, '{"token": "t"}'), None, "ok"),
289
+ (None, requests.exceptions.ReadTimeout("Read timed out."), "transient"),
290
+ (FakeResponse(503, "Service Unavailable", "text/plain"), None, "transient"),
291
+ (FakeResponse(200, "<html>Sign in</html>", "text/html"), None, "inconclusive"),
292
+ ])
293
+ def test_a_passing_or_unknown_login_lets_the_build_start(tool, monkeypatch,
294
+ response, exc, outcome):
295
+ call, sink, started, seen = tool
296
+ _post_returning(monkeypatch, response, exc)
297
+
298
+ out = call()
299
+
300
+ assert out["status"] == "started", out
301
+ assert started.wait(5), "worker never called _run_build"
302
+ # The outcome travels to the build's records on the call payload.
303
+ handed = list(seen["args"]) + list(seen["kwargs"].values())
304
+ payloads = [a for a in handed if isinstance(a, dict) and "brief" in a]
305
+ assert len(payloads) == 1, "the worker should be handed the call payload"
306
+ assert payloads[0]["login_precheck"]["outcome"] == outcome
307
+ assert sink.intake == [] and sink.result == [], "the build writes its own rows"
308
+
309
+
310
+ def test_the_build_result_row_carries_the_precheck(mcp, monkeypatch):
311
+ """_run_build's own result row (here: a build that dies at once) keeps it."""
312
+ rows = []
313
+ monkeypatch.setattr(mcp, "_recent_builds", collections.OrderedDict())
314
+ monkeypatch.setattr(mcp, "_last_build", None)
315
+ monkeypatch.setattr(mcp, "log_mcp_payload", lambda p: True)
316
+ monkeypatch.setattr(mcp, "log_mcp_result", lambda p: rows.append(p) or True)
317
+
318
+ def boom(**_kw):
319
+ raise RuntimeError("controller unavailable")
320
+
321
+ monkeypatch.setattr(mcp, "ChatDemoInterface", boom)
322
+ precheck = {"outcome": "transient", "blocking": False, "http_status": None,
323
+ "reason": "ReadTimeout: Read timed out.", "host": "h", "elapsed_ms": 10002}
324
+ out = mcp._run_build("r1", "brief", "Acme", "Custom Analytics", "", SECLOUD, "k",
325
+ OWNER, False, {"brief": "brief", "login_precheck": precheck})
326
+ assert out["status"] == "failed"
327
+ assert rows and rows[0]["login_precheck"] == precheck
328
+
329
+
330
+ def test_the_stored_result_row_keeps_the_precheck():
331
+ """log_mcp_result enumerates its keys, so check the row it would insert."""
332
+ from tests.test_payload_reaches_the_database import captured_result_row
333
+
334
+ precheck = {"outcome": "no_account", "blocking": True, "http_status": 400}
335
+ row = captured_result_row({"run_id": "r1", "status": "failed",
336
+ "login_precheck": precheck})
337
+ assert row["login_precheck"] == precheck
338
+ assert captured_result_row({"run_id": "r2", "status": "success"})["login_precheck"] is None
339
+
340
+
341
+ # --- the deployer's own login reports the real cause ------------------------ #
342
+ def _deployer(response):
343
+ class FakeSession:
344
+ def __init__(self):
345
+ self.headers, self.calls = {}, 0
346
+
347
+ def post(self, url, **kwargs):
348
+ self.calls += 1
349
+ return response
350
+
351
+ d = thoughtspot_deployer.ThoughtSpotDeployer.__new__(thoughtspot_deployer.ThoughtSpotDeployer)
352
+ d.base_url, d.username, d.secret_key = SEBE, OWNER, "secret-key-value"
353
+ d.session = FakeSession()
354
+ d.last_auth_status_code, d.last_auth_error = None, ""
355
+ return d
356
+
357
+
358
+ def test_deployer_names_the_standby_page_not_a_parse_error(monkeypatch):
359
+ monkeypatch.setattr(thoughtspot_deployer.time, "sleep", lambda s: None)
360
+ d = _deployer(FakeResponse(200, STANDBY_PAGE, "text/html; charset=utf-8"))
361
+ assert d.authenticate_trusted() is False
362
+ assert d.last_auth_error == standby_message(SEBE)
363
+ assert "Expecting value" not in d.last_auth_error
364
+ assert d.session.calls == 1, "a standby page does not clear in seconds β€” no retries"
365
+
366
+
367
+ def test_deployer_names_the_content_type_of_any_other_non_json_200(monkeypatch):
368
+ monkeypatch.setattr(thoughtspot_deployer.time, "sleep", lambda s: None)
369
+ d = _deployer(FakeResponse(200, "<html><body>Sign in with Okta</body></html>", "text/html"))
370
+ assert d.authenticate_trusted() is False
371
+ assert "non-JSON response" in d.last_auth_error
372
+ assert "content-type text/html" in d.last_auth_error
373
+ assert d.last_auth_error == non_json_auth_error(
374
+ SEBE, 200, "text/html", "<html><body>Sign in with Okta</body></html>")
375
+
376
+
377
+ def test_deploy_all_carries_the_cause_into_the_run_errors(monkeypatch):
378
+ """The run's error used to be the bare "ThoughtSpot authentication failed"."""
379
+ d = _deployer(None)
380
+ d.last_auth_error = standby_message(SEBE)
381
+ monkeypatch.setattr(d, "authenticate", lambda: False, raising=False)
382
+ results = d.deploy_all("CREATE TABLE t (id INT);", "DB", "SCHEMA", "base")
383
+ assert results["success"] is False
384
+ assert results["errors"][0] == (
385
+ f"ThoughtSpot authentication failed for {OWNER} on {SEBE}: {standby_message(SEBE)}")
386
+
387
+
388
+ def test_deploy_all_quotes_thoughtspots_reason_for_a_missing_account(monkeypatch):
389
+ d = _deployer(None)
390
+ d.last_auth_error = NO_ACCOUNT_BODY
391
+ monkeypatch.setattr(d, "authenticate", lambda: False, raising=False)
392
+ results = d.deploy_all("CREATE TABLE t (id INT);", "DB", "SCHEMA", "base")
393
+ assert "Specified user does not exist." in results["errors"][0]
394
+ assert "incident_id_guid" not in results["errors"][0]
395
+
396
+
397
+ def test_deploy_all_turns_a_missing_account_into_the_plain_instruction(monkeypatch):
398
+ """Findings triage 2026-10-15, E2: four builds failed at deploy with only
399
+ "ThoughtSpot authentication failed"; the run error now names the user, the
400
+ cluster and what to do, and still quotes ThoughtSpot."""
401
+ from thoughtspot_errors import no_account_message
402
+
403
+ d = _deployer(None)
404
+ d.last_auth_status_code, d.last_auth_error = 400, NO_ACCOUNT_BODY
405
+ monkeypatch.setattr(d, "authenticate", lambda: False, raising=False)
406
+ results = d.deploy_all("CREATE TABLE t (id INT);", "DB", "SCHEMA", "base")
407
+ assert results["errors"][0].startswith(no_account_message(OWNER, SEBE))
408
+ assert "Specified user does not exist." in results["errors"][0]
409
+ assert d.auth_failure_message() == results["errors"][0]
tests/test_measure_bounds.py CHANGED
@@ -86,7 +86,7 @@ def _spread(values):
86
  ("HEALTH_SCORE", {"mean": 85, "sigma": 0.3}, 0.0, 100.0),
87
  ("RENEWAL_RISK_SCORE", {"mean": 42, "sigma": 0.5}, 0.0, 100.0),
88
  ("SUPPLIER_QUALITY_INDEX", {"mean": 7.4}, 0.0, 10.0),
89
- ("CSAT_SCORE", {"mean": 4.3}, 0.0, 5.0),
90
  ("CREDIT_SCORE", {"mean": 715}, 0.0, 850.0),
91
  ("NPS_SCORE", {"mean": 40}, -100.0, 100.0),
92
  # rates on the convention their own params imply
@@ -121,7 +121,66 @@ def test_loss_and_cost_measures_are_non_negative(name):
121
  "FORECAST_ERROR_MW", "BUDGET_VARIANCE_USD", "LOAD_DEVIATION", "NET_CHANGE_MW",
122
  ])
123
  def test_signed_measures_are_not_forced_non_negative(name):
124
- assert infer_bound(name, "currency", {"mean": 0, "sigma": 500}) is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
 
127
  def test_a_score_with_no_declared_range_is_a_loud_gap_not_a_guess():
@@ -380,3 +439,164 @@ def test_a_quantity_is_not_mistaken_for_a_rate(name, params):
380
  bound = infer_bound(name, "float", params)
381
  assert bound is not None and bound.kind == "non_negative"
382
  assert bound.high is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  ("HEALTH_SCORE", {"mean": 85, "sigma": 0.3}, 0.0, 100.0),
87
  ("RENEWAL_RISK_SCORE", {"mean": 42, "sigma": 0.5}, 0.0, 100.0),
88
  ("SUPPLIER_QUALITY_INDEX", {"mean": 7.4}, 0.0, 10.0),
89
+ ("CSAT_SCORE", {"mean": 4.3}, 1.0, 5.0), # a 1-5 CSAT by name (NAMED_SCORE_SCALES)
90
  ("CREDIT_SCORE", {"mean": 715}, 0.0, 850.0),
91
  ("NPS_SCORE", {"mean": 40}, -100.0, 100.0),
92
  # rates on the convention their own params imply
 
121
  "FORECAST_ERROR_MW", "BUDGET_VARIANCE_USD", "LOAD_DEVIATION", "NET_CHANGE_MW",
122
  ])
123
  def test_signed_measures_are_not_forced_non_negative(name):
124
+ bound = infer_bound(name, "currency", {"mean": 0, "sigma": 500})
125
+ assert bound is not None and bound.kind == "signed"
126
+ assert bound.unbounded and not bound.gap and not bound.hard
127
+ assert bound.low is None and bound.high is None
128
+
129
+
130
+ # --- signed is an ANSWER, not a gap (findings triage 2026-10-15, A2) --------
131
+ #
132
+ # infer_bound used to return None both for "a variance has no bound" and for
133
+ # "nothing matched this name". The post-load gate could not tell them apart and
134
+ # posted NET_CASH_FLOW_USD, GROSS_MARGIN, OPERATING_MARGIN, SPEND_VARIANCE_USD
135
+ # and NET_ENROLLMENT_CHANGE as vocabulary gaps on every build that had them.
136
+
137
+
138
+ @pytest.mark.parametrize("name,fmt", [
139
+ ("NET_CASH_FLOW_USD", "currency"), ("GROSS_MARGIN", "currency"),
140
+ ("OPERATING_MARGIN", "float"), ("SPEND_VARIANCE_USD", "currency"),
141
+ ("NET_ENROLLMENT_CHANGE", "int"), ("NET_INCOME", "currency"),
142
+ # the gate's name-only call: no format at all
143
+ ("NET_CASH_FLOW_USD", ""), ("GROSS_MARGIN", ""), ("NET_INCOME", ""),
144
+ ])
145
+ def test_signed_by_design_is_a_signed_bound_not_none(name, fmt):
146
+ bound = infer_bound(name, fmt, {"mean": 1200.0})
147
+ assert bound is not None, f"{name}: a signed quantity must not read as 'no class matched'"
148
+ assert bound.kind == "signed" and bound.unbounded and bound.gap == ""
149
+ assert not bound.violates(-5.0) and not bound.violates(1e9)
150
+
151
+
152
+ def test_a_declared_signed_flag_and_a_difference_formula_are_signed_too():
153
+ declared = infer_bound("ADJUSTED_SPEND_USD", "currency", {"mean": 100}, signed=True)
154
+ assert declared is not None and declared.kind == "signed" and "declared" in declared.source
155
+ derived = infer_bound("SPOT_FORECAST_SPREAD_USD", "currency", {}, formula="SPOT - FORECAST")
156
+ assert derived is not None and derived.kind == "signed" and "difference" in derived.source
157
+ # an explicit non-negative token still wins over the formula's shape
158
+ assert infer_bound("PRICE_GAP_USD", "currency", {}, formula="SPOT - FORECAST").kind == "non_negative"
159
+
160
+
161
+ def test_a_leading_net_never_widens_a_rate_or_a_score():
162
+ # NET_COLLECTION_RATE is a 0-1 rate; the proportion class answers first.
163
+ rate = infer_bound("NET_COLLECTION_RATE", "float", {"mean": 0.93})
164
+ assert (rate.kind, rate.low, rate.high) == ("rate", 0.0, 1.0)
165
+ nps = infer_bound("NET_PROMOTER_SCORE", "float", {"mean": 40})
166
+ assert nps.kind == "score"
167
+
168
+
169
+ # --- the format is evidence (A1): a count or an amount is non-negative --------
170
+
171
+
172
+ @pytest.mark.parametrize("name,fmt", [
173
+ # names with NO vocabulary at all, so the format is the only evidence
174
+ ("GIZMOS", "int"), ("WIDGETS_FROBNICATED", "int"), ("THINGAMAJIGS", "int"),
175
+ ("DOUBLOONS", "currency"), ("GALLEONS_QUIDS", "currency"), ("ZORKMIDS", "currency"),
176
+ ])
177
+ def test_an_int_count_or_a_currency_amount_is_non_negative_when_the_format_is_known(name, fmt):
178
+ bound = infer_bound(name, fmt, {"mean": 4200.0})
179
+ assert bound is not None and bound.kind == "non_negative" and bound.low == 0.0
180
+ assert bound.hard is (fmt == "int")
181
+ # Without the format these names have NO vocabulary: that is the exact
182
+ # None the post-load gate used to receive for every count and amount.
183
+ assert infer_bound(name, "", {"mean": 4200.0}) is None
184
 
185
 
186
  def test_a_score_with_no_declared_range_is_a_loud_gap_not_a_guess():
 
439
  bound = infer_bound(name, "float", params)
440
  assert bound is not None and bound.kind == "non_negative"
441
  assert bound.high is None
442
+
443
+
444
+ # --- Wave 2 of the findings triage (2026-10-15): vocabulary, scores, open ratios
445
+
446
+
447
+ @pytest.mark.parametrize("name,kind", [
448
+ # currencies (five Canadian prospects in three days) and money nouns
449
+ ("AUM_CAD", "non_negative"), ("GROSS_SALES_AUD", "non_negative"), ("TOTAL_DOLLARS", "non_negative"),
450
+ ("COGS", "non_negative"), ("GMV", "non_negative"), ("COMMISSION", "non_negative"),
451
+ ("AVG_FARE", "non_negative"), ("MSRP", "non_negative"), ("ANNUAL_BUDGET", "non_negative"),
452
+ ("TUITION_PAID", "non_negative"), ("REIMBURSEMENT", "non_negative"),
453
+ # counts: plural nouns and counted events
454
+ ("SESSIONS", "non_negative"), ("ORDERS_SHIPPED", "non_negative"), ("CASES_PICKED", "non_negative"),
455
+ ("TICKETS_RESOLVED", "non_negative"), ("ACCOUNTS_CREATED", "non_negative"), ("PATIENTS", "non_negative"),
456
+ # physical units
457
+ ("HAUL_DISTANCE_FT", "non_negative"), ("PAYLOAD_KG", "non_negative"), ("RESPONSE_MS", "non_negative"),
458
+ ("YEARS_OF_SERVICE", "non_negative"), ("FUEL_EFFICIENCY_MPG", "non_negative"),
459
+ # proportion words
460
+ ("THROUGHPUT_EFFICIENCY", "rate"), ("LEAD_ATTRIBUTION_COMPLETENESS", "rate"),
461
+ ("SEAT_AVAILABILITY", "rate"), ("FORECAST_ACCURACY", "rate"), ("LOAD_FACTOR", "rate"),
462
+ ("SELL_THROUGH", "rate"),
463
+ ])
464
+ def test_the_vocabulary_added_after_the_october_flood_classifies_the_names_that_flooded(name, kind):
465
+ bound = infer_bound(name, "", {"mean": 0.7 if kind == "rate" else 4200.0})
466
+ assert bound is not None and bound.kind == kind, f"{name}: {bound and bound.describe()}"
467
+
468
+
469
+ def test_a_net_quantity_still_wins_over_a_money_noun():
470
+ assert infer_bound("NET_INCOME", "", {"mean": 1200.0}).kind == "signed"
471
+ assert infer_bound("NET_PRESENT_VALUE", "", {"mean": 1200.0}).kind == "signed"
472
+
473
+
474
+ @pytest.mark.parametrize("name,params,low,high", [
475
+ ("MEDIAN_SAT_SCORE", {"mean": 1530}, 400.0, 1600.0), # fell off the ladder before
476
+ ("AVG_GPA", {"mean": 3.4}, 0.0, 4.0),
477
+ ("WEIGHTED_GPA", {"mean": 4.3}, 0.0, 5.0),
478
+ ("ACT_SCORE", {"mean": 24}, 1.0, 36.0),
479
+ ("AVG_CSAT", {"mean": 4.1}, 1.0, 5.0),
480
+ ("CSAT_SCORE", {"mean": 82}, 0.0, 100.0), # a 0-100 CSAT reads off the ladder
481
+ ("CSAT_SCORE", {"low": 0, "high": 10}, 0.0, 10.0), # a declared range wins
482
+ ("SAT_SCORE", {"mean": 4.2}, 0.0, 5.0), # 'SAT' as satisfaction, not the exam
483
+ ])
484
+ def test_named_score_scales(name, params, low, high):
485
+ bound = infer_bound(name, "float", params)
486
+ assert bound is not None and bound.kind == "score" and not bound.gap, bound
487
+ assert (bound.low, bound.high) == (low, high), bound.describe()
488
+
489
+
490
+ def test_a_sum_of_scores_is_a_quantity_not_a_score():
491
+ from demoprep_app.dataset.measure_names import is_score_column
492
+
493
+ assert not is_score_column("CSAT_SCORE_SUM") and not is_score_column("TOTAL_RISK_SCORE")
494
+ bound = infer_bound("CSAT_SCORE_SUM", "float", {"mean": 1200})
495
+ assert bound is not None and bound.kind == "non_negative" and not bound.gap
496
+ # an NPS sum can be negative, so it is not forced non-negative
497
+ nps_sum = infer_bound("NPS_SCORE_SUM", "float", {"mean": 40})
498
+ assert nps_sum is None or nps_sum.kind != "non_negative"
499
+
500
+
501
+ @pytest.mark.parametrize("name", [
502
+ "PIPELINE_COVERAGE_RATIO", "COMBINED_RATIO", "LOSS_RATIO", "INVENTORY_TURNOVER_RATIO",
503
+ "INVENTORY_TURNS", "BOOK_TO_BILL", "LTV_CAC_RATIO", "DEBT_TO_EQUITY_RATIO", "CURRENT_RATIO",
504
+ "PIPELINE_COVERAGE",
505
+ ])
506
+ def test_an_open_ratio_has_a_floor_and_no_ceiling(name):
507
+ from demoprep_app.dataset.measure_names import is_open_ratio_name, is_rate_column
508
+
509
+ assert is_open_ratio_name(name)
510
+ for fmt in ("float", "pct"):
511
+ bound = infer_bound(name, fmt, {"mean": 1.4})
512
+ assert bound is not None and (bound.low, bound.high) == (0.0, None), f"{name}/{fmt}: {bound.describe()}"
513
+ assert bound.kind == "non_negative" and not bound.hard
514
+ # Bruker's board: a 3.2x coverage must not be "out of bounds"
515
+ assert not infer_bound(name, "float", {"mean": 1.4}).violates(3.2)
516
+ # the aggregation is unchanged: a _RATIO still averages
517
+ if name.endswith("_RATIO"):
518
+ assert is_rate_column(name)
519
+
520
+
521
+ @pytest.mark.parametrize("name", ["TURNOVER_RATE", "EMPLOYEE_TURNOVER_PCT", "LOSS_RATE", "COVERAGE_PCT"])
522
+ def test_a_rate_that_merely_shares_a_word_with_an_open_ratio_stays_bounded(name):
523
+ from demoprep_app.dataset.measure_names import is_open_ratio_name
524
+
525
+ assert not is_open_ratio_name(name)
526
+ assert infer_bound(name, "float", {"mean": 0.15}).kind == "rate"
527
+
528
+
529
+ def test_harden_turns_an_open_ratio_declared_pct_into_a_float():
530
+ bp = _blueprint([
531
+ {"name": "PREMIUMS_EARNED", "fmt": "currency", "params": {"mean": 50000}},
532
+ {"name": "COMBINED_RATIO", "fmt": "pct", "params": {"mean": 0.98, "sigma": 0.1}},
533
+ ])
534
+ measure = next(m for m in bp.facts[0].measures if m.name == "COMBINED_RATIO")
535
+ assert measure.fmt == "float"
536
+ assert any("COMBINED_RATIO: fmt 'pct' -> 'float'" in fix for fix in bp.metadata.get("hardening", []))
537
+ values = _series(bp, "COMBINED_RATIO")
538
+ assert max(values) > 1.0, "an open ratio must be allowed above 1"
539
+
540
+
541
+ def test_a_pile_up_on_a_derived_ratio_tells_the_author_to_declare_share_of():
542
+ from demoprep_app.dataset.engine import CAP_PILEUP_WARN_SHARE
543
+ from demoprep_app.dataset.measure_bounds import MeasureBound
544
+ from demoprep_app.scenario.blueprint import BlueprintMeasure
545
+
546
+ engine = BlueprintEngine()
547
+ derived = BlueprintMeasure(name="FILL_RATE", kind="derived", formula="FILLED / ORDERED", fmt="pct",
548
+ bound=MeasureBound(0.0, 1.0, "rate", "proportion"))
549
+ base = BlueprintMeasure(name="UTILIZATION_RATE", kind="base", fmt="pct", params={"mean": 0.9},
550
+ bound=MeasureBound(0.0, 1.0, "rate", "proportion"))
551
+ for _ in range(20):
552
+ engine._store(derived, 1.0)
553
+ engine._store(base, 1.0)
554
+ by_name = {f.measure: f for f in engine.bound_findings() if f.code == "measure_bound_pileup"}
555
+ assert set(by_name) == {"FILL_RATE", "UTILIZATION_RATE"} and CAP_PILEUP_WARN_SHARE < 1.0
556
+ assert "share_of" in by_name["FILL_RATE"].suggestion and "FILLED / ORDERED" in by_name["FILL_RATE"].suggestion
557
+ assert "params.mean" not in by_name["FILL_RATE"].suggestion
558
+ assert "params.mean" in by_name["UTILIZATION_RATE"].suggestion
559
+
560
+
561
+ # --- Wave 3 (C2): a margin rate is bounded through its difference -------------
562
+
563
+ def _margin_blueprint(rate_formula: str, gross_margin: bool = True):
564
+ measures = [
565
+ {"name": "NET_SALES", "fmt": "currency", "params": {"mean": 50000, "sigma": 0.4}},
566
+ {"name": "COGS", "fmt": "currency", "params": {"mean": 38000, "sigma": 0.4}},
567
+ ]
568
+ if gross_margin:
569
+ measures.append({"name": "GROSS_MARGIN", "kind": "derived", "fmt": "currency",
570
+ "formula": "NET_SALES - COGS"})
571
+ measures.append({"name": "GROSS_MARGIN_RATE", "kind": "derived", "fmt": "pct", "formula": rate_formula})
572
+ return _blueprint(measures, fact="RETAIL_SALES")
573
+
574
+
575
+ @pytest.mark.parametrize("formula,gross_margin", [
576
+ ("GROSS_MARGIN / NET_SALES", True), # Loblaw's shape: a derived numerator
577
+ ("(NET_SALES - COGS) / NET_SALES", False), # the margin written inline
578
+ ])
579
+ def test_a_margin_rate_bounds_the_cost_as_a_share_of_sales_instead_of_clamping(formula, gross_margin):
580
+ bp = _margin_blueprint(formula, gross_margin)
581
+ cogs = next(m for m in bp.facts[0].measures if m.name == "COGS")
582
+ rate = next(m for m in bp.facts[0].measures if m.name == "GROSS_MARGIN_RATE")
583
+ assert cogs.share_of == "NET_SALES", bp.metadata.get("hardening")
584
+ assert any("COGS: inferred share_of NET_SALES from GROSS_MARGIN_RATE" in f for f in bp.metadata["hardening"])
585
+ assert not rate.formula.startswith("MIN(MAX("), "bounded by construction β€” no wrap needed"
586
+
587
+ values = _series(bp, "GROSS_MARGIN_RATE", fact="RETAIL_SALES")
588
+ assert 0.0 <= min(values) and max(values) <= 1.0
589
+ at_cap = sum(1 for v in values if v >= 0.999) / len(values)
590
+ assert at_cap < 0.01, f"{at_cap:.1%} of rows pinned at the cap"
591
+
592
+
593
+ def test_a_rate_that_cannot_be_bounded_structurally_says_why():
594
+ bp = _blueprint([
595
+ {"name": "UNITS_ORDERED", "fmt": "int", "params": {"mean": 500, "sigma": 0.4}},
596
+ {"name": "UNITS_SHIPPED", "fmt": "int", "params": {"mean": 450, "sigma": 0.4}},
597
+ {"name": "UNITS_FILLED", "kind": "derived", "fmt": "int", "formula": "UNITS_SHIPPED * 0.9"},
598
+ {"name": "FILL_RATE", "kind": "derived", "fmt": "pct", "formula": "UNITS_FILLED / UNITS_ORDERED"},
599
+ ], fact="ORDERS")
600
+ notes = [f for f in bp.metadata["hardening"] if f.startswith("[BOUND-NOTE] FILL_RATE")]
601
+ assert len(notes) == 1
602
+ assert "UNITS_FILLED is derived" in notes[0] and "wrapped in MIN/MAX" in notes[0]
tests/test_measure_formula.py CHANGED
@@ -12,6 +12,7 @@ import pytest
12
 
13
  from demoprep_app.dataset.formula import (
14
  FormulaEvaluationError,
 
15
  evaluate,
16
  identifiers,
17
  ratio_pairs,
@@ -107,6 +108,39 @@ def test_validate_rejects_bad_arity():
107
  assert problems and "ABS()" in problems[0]
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  # --- helpers ----------------------------------------------------------------
111
 
112
 
 
12
 
13
  from demoprep_app.dataset.formula import (
14
  FormulaEvaluationError,
15
+ alias_target,
16
  evaluate,
17
  identifiers,
18
  ratio_pairs,
 
108
  assert problems and "ABS()" in problems[0]
109
 
110
 
111
+ # --- identity formulas are copies (findings triage 2026-10-15, D) -------------
112
+ #
113
+ # Humpty Dump's DAYS_ON_SITE = RENTAL_DAYS * 1 passed validation (the
114
+ # identifier existed), stored a copy, was caught post-load as a mirror,
115
+ # "repaired" by recomputing the same identity and dropped as repair_failed β€”
116
+ # three builds in 70 minutes for one measure. The guard belongs BEFORE
117
+ # generation, where the repair loop can demote or drop it.
118
+
119
+
120
+ @pytest.mark.parametrize("formula", [
121
+ "RENTAL_DAYS * 1", "1 * RENTAL_DAYS", "RENTAL_DAYS / 1", "RENTAL_DAYS + 0",
122
+ "0 + RENTAL_DAYS", "RENTAL_DAYS - 0", "RENTAL_DAYS * 1.0", "(RENTAL_DAYS * 1) / 1",
123
+ "ROUND(RENTAL_DAYS * 1, 2)", "RENTAL_DAYS",
124
+ ])
125
+ def test_an_identity_formula_is_reported_as_a_copy(formula):
126
+ assert alias_target(formula) == "RENTAL_DAYS"
127
+ problems = validate(formula, {"RENTAL_DAYS", "IDLE_DAYS"}, measure="DAYS_ON_SITE")
128
+ assert len(problems) == 1, problems
129
+ assert "RENTAL_DAYS" in problems[0] and "identical" in problems[0]
130
+ shape = "bare alias" if formula == "RENTAL_DAYS" else "arithmetic identity"
131
+ assert shape in problems[0]
132
+
133
+
134
+ @pytest.mark.parametrize("formula", [
135
+ "RENTAL_DAYS * 2", "RENTAL_DAYS / 100", "RENTAL_DAYS * 1.07", "RENTAL_DAYS + IDLE_DAYS",
136
+ "RENTAL_DAYS - IDLE_DAYS", "1 - RENTAL_DAYS", "1 / RENTAL_DAYS", "RENTAL_DAYS * (2 - 1)",
137
+ "ABS(RENTAL_DAYS)",
138
+ ])
139
+ def test_a_real_computation_is_not_an_identity(formula):
140
+ assert alias_target(formula) == ""
141
+ assert validate(formula, {"RENTAL_DAYS", "IDLE_DAYS"}, measure="DAYS_ON_SITE") == []
142
+
143
+
144
  # --- helpers ----------------------------------------------------------------
145
 
146
 
tests/test_portal_url_round_trip.py CHANGED
@@ -19,6 +19,14 @@ def _mcp_source() -> str:
19
  return inspect.getsource(mcp_server)
20
 
21
 
 
 
 
 
 
 
 
 
22
  def test_status_view_carries_portal_url():
23
  """_recent_builds is what status() serves; it must include the site."""
24
  src = _mcp_source()
@@ -28,7 +36,7 @@ def test_status_view_carries_portal_url():
28
 
29
  def test_durable_result_payload_carries_portal_url():
30
  """The payload handed to log_mcp_result must include the site."""
31
- src = _mcp_source()
32
  block = src.split("log_mcp_result({", 1)[1].split("})", 1)[0]
33
  assert "portal_url" in block, "the durable record would omit the built site"
34
 
@@ -65,7 +73,7 @@ def test_status_view_carries_download_url():
65
 
66
 
67
  def test_durable_result_payload_carries_download_url():
68
- src = _mcp_source()
69
  block = src.split("log_mcp_result({", 1)[1].split("})", 1)[0]
70
  assert "download_url" in block, "the durable record would omit the source zip"
71
 
 
19
  return inspect.getsource(mcp_server)
20
 
21
 
22
+ def _build_result_source() -> str:
23
+ """_run_build only: the build's result payload. Another log_mcp_result call
24
+ (the ISS-22 login-precheck refusal, which builds nothing) must not be the
25
+ block these checks land on."""
26
+ import mcp_server
27
+ return inspect.getsource(mcp_server._run_build)
28
+
29
+
30
  def test_status_view_carries_portal_url():
31
  """_recent_builds is what status() serves; it must include the site."""
32
  src = _mcp_source()
 
36
 
37
  def test_durable_result_payload_carries_portal_url():
38
  """The payload handed to log_mcp_result must include the site."""
39
+ src = _build_result_source()
40
  block = src.split("log_mcp_result({", 1)[1].split("})", 1)[0]
41
  assert "portal_url" in block, "the durable record would omit the built site"
42
 
 
73
 
74
 
75
  def test_durable_result_payload_carries_download_url():
76
+ src = _build_result_source()
77
  block = src.split("log_mcp_result({", 1)[1].split("})", 1)[0]
78
  assert "download_url" in block, "the durable record would omit the source zip"
79
 
tests/test_quality_gate.py CHANGED
@@ -778,3 +778,146 @@ def test_a_score_with_no_inferable_scale_is_warned_not_bounded():
778
  assert any("READINESS_SCORE" in w and "standard score scale" in w for w in profile["warnings"]), \
779
  profile["warnings"]
780
  assert cursor.sql_of_kind("UPDATE") == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  assert any("READINESS_SCORE" in w and "standard score scale" in w for w in profile["warnings"]), \
779
  profile["warnings"]
780
  assert cursor.sql_of_kind("UPDATE") == []
781
+
782
+
783
+ # --------------------------------------------------------------------------- #
784
+ # Findings triage 2026-10-15, Wave 1 β€” the gate reads the blueprint's formats,
785
+ # tells "signed" from "unclassified", and never recomputes a mirror.
786
+ # --------------------------------------------------------------------------- #
787
+
788
+ # GIZMOS / DOUBLOONS stand in for the SESSIONS / COGS of the 2026-10 flood:
789
+ # names with no vocabulary at all, so the declared format is the only evidence
790
+ # (Wave 2 added count and money vocabulary that now classifies the real ones).
791
+ _SALES_COLUMNS = [
792
+ ("SALES", "GIZMOS", "NUMBER(38,0)"),
793
+ ("SALES", "DOUBLOONS", "NUMBER(16,2)"),
794
+ ("SALES", "NET_CASH_FLOW_USD", "NUMBER(16,2)"),
795
+ ("SALES", "GROSS_MARGIN", "NUMBER(16,2)"),
796
+ ]
797
+ _SALES_STATS = [
798
+ ("GIZMOS", {"non_zero": 900, "min": 3.0, "max": 410.0, "avg": 88.0}),
799
+ ("DOUBLOONS", {"non_zero": 900, "min": 12.0, "max": 9800.0, "avg": 1400.0}),
800
+ ("NET_CASH_FLOW_USD", {"non_zero": 900, "min": -8000.0, "max": 9500.0, "avg": 300.0}),
801
+ ("GROSS_MARGIN", {"non_zero": 900, "min": -200.0, "max": 4100.0, "avg": 1200.0}),
802
+ ]
803
+ _SALES_FORMATS = {
804
+ "SALES.GIZMOS": {"fmt": "int", "signed": False, "kind": "base"},
805
+ "SALES.DOUBLOONS": {"fmt": "currency", "signed": False, "kind": "base"},
806
+ "SALES.NET_CASH_FLOW_USD": {"fmt": "currency", "signed": False, "kind": "derived"},
807
+ "SALES.GROSS_MARGIN": {"fmt": "currency", "signed": False, "kind": "derived"},
808
+ }
809
+
810
+
811
+ def _unclassified(profile):
812
+ return sorted(f["measure"] for f in profile["findings"] if f["code"] == "bound_not_inferable")
813
+
814
+
815
+ def test_name_only_the_gate_leaves_counts_and_amounts_unclassified_but_never_a_signed_name():
816
+ """The 2026-10-12..15 flood: SESSIONS / COGS came back None from a
817
+ name-only call. NET_/MARGIN names did too β€” and no longer do (A2)."""
818
+ cursor = FakeCursor(columns_rows=_SALES_COLUMNS, profiles={"SALES": [profile_row(900, _SALES_STATS)]})
819
+ profile = run_measure_quality_gate(FakeConnection(cursor), "X_sch")
820
+ assert _unclassified(profile) == ["DOUBLOONS", "GIZMOS"]
821
+ # a registry gap is `info`, kept for the record β€” not a warning per measure
822
+ assert {f["severity"] for f in profile["findings"] if f["code"] == "bound_not_inferable"} == {"info"}
823
+ assert profile["defects"] == []
824
+
825
+
826
+ def test_with_the_blueprints_formats_nothing_is_unclassified_and_nothing_is_invented():
827
+ cursor = FakeCursor(columns_rows=_SALES_COLUMNS, profiles={"SALES": [profile_row(900, _SALES_STATS)]})
828
+ profile = run_measure_quality_gate(FakeConnection(cursor), "X_sch", formats=_SALES_FORMATS)
829
+ assert _unclassified(profile) == []
830
+ # the negatives on the signed pair are legal; a currency amount is a soft
831
+ # bound; the int count is in range β€” no defect, no repair
832
+ assert profile["defects"] == [] and profile["repairs"] == []
833
+
834
+
835
+ def test_a_negative_int_count_is_caught_once_the_format_is_known():
836
+ stats = [("GIZMOS", {"non_zero": 900, "min": -4.0, "max": 410.0, "avg": 88.0})]
837
+ cursor = FakeCursor(columns_rows=_SALES_COLUMNS[:1], profiles={"SALES": [profile_row(900, stats)] * 2})
838
+ profile = run_measure_quality_gate(
839
+ FakeConnection(cursor), "X_sch", formats=_SALES_FORMATS, repair=False,
840
+ )
841
+ assert [d["defect"] for d in profile["defects"]] == ["measure_out_of_bounds"]
842
+ assert profile["defects"][0]["bound_kind"] == "non_negative"
843
+
844
+
845
+ def test_measure_formats_reads_the_declared_semantics_off_the_bundle():
846
+ from demoprep_app.dataset.contracts import DatasetBundle, DatasetColumn, DatasetTable
847
+ from demoprep_app.integrations.snowflake.quality_gate import measure_formats
848
+
849
+ table = DatasetTable("FACT_F", "one row per day", [
850
+ DatasetColumn("FACT_F_KEY", "fact_key", "NUMBER", nullable=False),
851
+ DatasetColumn("SESSIONS", "sessions", "NUMBER(38,0)", fmt="int", measure_kind="base"),
852
+ DatasetColumn("PRICE_ERROR", "price_error", "NUMBER(16,2)", formula="SPOT - FORECAST",
853
+ fmt="currency", signed=True, measure_kind="derived"),
854
+ ], is_fact=True)
855
+ assert measure_formats(DatasetBundle(scenario=None, tables=[table])) == {
856
+ "FACT_F.SESSIONS": {"fmt": "int", "signed": False, "kind": "base"},
857
+ "FACT_F.PRICE_ERROR": {"fmt": "currency", "signed": True, "kind": "derived"},
858
+ }
859
+
860
+
861
+ def test_a_mirror_is_omitted_without_a_recompute_and_names_the_identity():
862
+ """Humpty Dump run 2 (2026-10-13): DAYS_ON_SITE = RENTAL_DAYS * 1. The gate
863
+ recomputed it from the same identity, re-profiled, still mirrored, posted
864
+ repair_failed AND the mirror, and dropped the column β€” three builds."""
865
+ days = {"non_zero": 3000, "min": 1.0, "max": 44.0, "avg": 9.2}
866
+ money = {"non_zero": 3000, "min": 120.0, "max": 9800.0, "avg": 1400.0}
867
+ # five measures, so one dropped column (20%) stays under the 25% abort line
868
+ cursor = FakeCursor(
869
+ columns_rows=[
870
+ ("RENTAL", "RENTAL_DAYS", "NUMBER(38,0)"),
871
+ ("RENTAL", "DAYS_ON_SITE", "NUMBER(38,0)"),
872
+ ("RENTAL", "RENTAL_REVENUE_USD", "NUMBER(16,2)"),
873
+ ("RENTAL", "HAUL_COST_USD", "NUMBER(16,2)"),
874
+ ("RENTAL", "DISPOSAL_FEES_USD", "NUMBER(16,2)"),
875
+ ],
876
+ profiles={"RENTAL": [profile_row(3000, [
877
+ ("RENTAL_DAYS", days), ("DAYS_ON_SITE", dict(days)),
878
+ ("RENTAL_REVENUE_USD", money), ("HAUL_COST_USD", dict(money)),
879
+ ("DISPOSAL_FEES_USD", {"non_zero": 3000, "min": 20.0, "max": 900.0, "avg": 140.0}),
880
+ ])]},
881
+ mirror={"RENTAL": [(0,)]},
882
+ )
883
+ profile = run_measure_quality_gate(
884
+ FakeConnection(cursor), "HUM_sch", formulas={"RENTAL.DAYS_ON_SITE": "RENTAL_DAYS * 1"},
885
+ )
886
+
887
+ assert [d["defect"] for d in profile["defects"]] == ["mirrors_other_column"]
888
+ assert cursor.sql_of_kind("UPDATE") == [], "a mirror is never recomputed"
889
+ assert profile["repair_failures"] == [] and profile["repairs"] == []
890
+ assert [o["column"] for o in profile["omissions"]] == ["DAYS_ON_SITE"]
891
+ assert [f["code"] for f in profile["findings"]] == ["measure_mirrors_other_column"]
892
+ defect = profile["defects"][0]
893
+ assert "identity of RENTAL_DAYS" in defect["detail"] and "RENTAL_DAYS * 1" in defect["detail"]
894
+ assert "copy of RENTAL_DAYS" in defect["suggestion"]
895
+ assert "not a measure of" not in defect["suggestion"]
896
+
897
+
898
+ def test_a_hollow_difference_names_the_input_that_contributed_nothing():
899
+ from demoprep_app.integrations.snowflake.quality_gate import _mirror_explanation
900
+
901
+ detail, suggestion = _mirror_explanation(
902
+ "FACT_F", "PRICE_ERROR", "FORECAST_PRICE", -1, "SPOT_PRICE_ACTUAL - FORECAST_PRICE",
903
+ )
904
+ assert detail.startswith("every row equals -FORECAST_PRICE")
905
+ assert "SPOT_PRICE_ACTUAL" in detail and "contributed nothing" in detail
906
+ assert "SPOT_PRICE_ACTUAL must exist in FACT_F and vary" in suggestion
907
+
908
+
909
+ def test_the_semantic_checks_never_probe_an_open_ratio_against_zero_to_one():
910
+ """PIPELINE_COVERAGE_RATIO at 3.2x is not 'outside [0, 1]' (triage C1)."""
911
+ cursor = FakeCursor(
912
+ columns_rows=[("DEALS", "PIPELINE_COVERAGE_RATIO", "NUMBER(16,4)"),
913
+ ("DEALS", "WIN_RATE", "NUMBER(16,4)")],
914
+ profiles={"DEALS": [profile_row(400, [
915
+ ("PIPELINE_COVERAGE_RATIO", {"non_zero": 400, "min": 0.8, "max": 4.1, "avg": 2.6}),
916
+ ("WIN_RATE", {"non_zero": 400, "min": 0.1, "max": 0.6, "avg": 0.3}),
917
+ ])]},
918
+ )
919
+ profile = run_measure_quality_gate(FakeConnection(cursor), "BRU_sch")
920
+ probes = [sql for sql in cursor.executed if sql.startswith("SELECT COUNT(*), MIN(")]
921
+ assert len(probes) == 1 and '"WIN_RATE"' in probes[0], probes
922
+ assert profile["defects"] == [], "a coverage ratio above 1 is not out of bounds"
923
+ assert not any("PIPELINE_COVERAGE_RATIO" in w for w in profile["semantic_warnings"])
tests/test_tuning_round_findings.py CHANGED
@@ -171,9 +171,11 @@ def test_an_out_of_range_escape_remeasured_every_round_surfaces_once(monkeypatch
171
  # Guard: the fix is per round, not a looser key
172
  # --------------------------------------------------------------------------- #
173
  def test_distinct_messages_for_one_measure_are_still_distinct_findings():
174
- """Two different statements about one measure at one stage (say, piled on
175
- the high bound AND on a non-zero low bound, or two facts with two bounds)
176
- are two defects. Collapsing on (code, stage, table, measure) would hide one."""
 
 
177
  common = dict(code="measure_bound_pileup", severity="warning", stage="generation",
178
  suggestion="fix the formula's inputs", measure=RATE, table=FACT)
179
  high = Finding(message="231/3333 row(s) sit ON the high bound", **common)
 
171
  # Guard: the fix is per round, not a looser key
172
  # --------------------------------------------------------------------------- #
173
  def test_distinct_messages_for_one_measure_are_still_distinct_findings():
174
+ """Two different statements about one measure at ONE stage (say, piled on
175
+ the high bound AND on a non-zero low bound) are two defects, and both are
176
+ kept. The cross-stage collapse (findings triage 2026-10-15, A3: blueprint
177
+ and post-load both naming CSAT_SCORE_SUM) keeps the later stage's records
178
+ and never merges two messages from the same stage."""
179
  common = dict(code="measure_bound_pileup", severity="warning", stage="generation",
180
  suggestion="fix the formula's inputs", measure=RATE, table=FACT)
181
  high = Finding(message="231/3333 row(s) sit ON the high bound", **common)
thoughtspot_deployer.py CHANGED
@@ -29,13 +29,33 @@ from datetime import datetime
29
  from typing import Dict, List, Optional, Tuple
30
  from dotenv import load_dotenv
31
  from snowflake_auth import get_snowflake_connection_params
32
- from thoughtspot_errors import friendly_mcp_liveboard_error
 
 
 
 
 
33
  from demoprep_app.dataset.measure_names import default_aggregation
34
 
35
  # Load environment variables
36
  load_dotenv()
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def _get_code_version() -> str:
40
  """Return the deployed code version for run lineage/debugging."""
41
  for env_name in ("DEMO_PREP_COMMIT", "SPACE_COMMIT_SHA", "GIT_COMMIT", "COMMIT_SHA"):
@@ -338,11 +358,8 @@ class ThoughtSpotDeployer:
338
  self.sf_role = get_admin_setting('SNOWFLAKE_ROLE')
339
  self.sf_warehouse = get_admin_setting('SNOWFLAKE_WAREHOUSE')
340
 
341
- self.headers = {
342
- 'Content-Type': 'application/json',
343
- 'X-Requested-By': 'ThoughtSpot'
344
- }
345
-
346
  # Use session to maintain cookies between requests
347
  self.session = requests.Session()
348
  self.session.headers.update(self.headers)
@@ -409,7 +426,7 @@ class ThoughtSpotDeployer:
409
  """Authenticate with ThoughtSpot using trusted authentication (secret key)"""
410
  self.last_auth_status_code = None
411
  self.last_auth_error = ""
412
- auth_url = f"{self.base_url}/api/rest/2.0/auth/token/full"
413
  max_attempts = max(1, int(os.getenv("TS_AUTH_MAX_ATTEMPTS", "3")))
414
  base_wait_seconds = max(1, int(os.getenv("TS_AUTH_RETRY_WAIT_SECONDS", "5")))
415
 
@@ -422,11 +439,7 @@ class ThoughtSpotDeployer:
422
 
423
  response = self.session.post(
424
  auth_url,
425
- json={
426
- "username": self.username,
427
- "secret_key": self.secret_key,
428
- "validity_time_in_sec": 3600 # 1 hour token
429
- },
430
  timeout=60,
431
  )
432
 
@@ -434,7 +447,22 @@ class ThoughtSpotDeployer:
434
  self.last_auth_status_code = response.status_code
435
 
436
  if response.status_code == 200:
437
- result = response.json()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  if 'token' in result:
439
  # Use the token as bearer auth
440
  self.session.headers['Authorization'] = f'Bearer {result["token"]}'
@@ -467,6 +495,14 @@ class ThoughtSpotDeployer:
467
 
468
  return False
469
 
 
 
 
 
 
 
 
 
470
  def authenticate_oauth(self, timeout: int = 120) -> bool:
471
  """
472
  Authenticate with ThoughtSpot using browser-based SSO (Okta, SAML, etc.)
@@ -2952,7 +2988,11 @@ class ThoughtSpotDeployer:
2952
  ts_url=self.base_url,
2953
  username=self.username,
2954
  )
2955
- raise Exception("ThoughtSpot authentication failed")
 
 
 
 
2956
  auth_time = time.time() - start_time
2957
  log_progress(f"[OK] Auth complete ({auth_time:.1f}s)")
2958
  if _slog:
 
29
  from typing import Dict, List, Optional, Tuple
30
  from dotenv import load_dotenv
31
  from snowflake_auth import get_snowflake_connection_params
32
+ from thoughtspot_errors import (
33
+ friendly_mcp_liveboard_error,
34
+ non_json_auth_error,
35
+ trusted_auth_failure_message,
36
+ ts_error_reason,
37
+ )
38
  from demoprep_app.dataset.measure_names import default_aggregation
39
 
40
  # Load environment variables
41
  load_dotenv()
42
 
43
 
44
+ # The trusted-auth token request. Shared by ThoughtSpotDeployer.authenticate_trusted()
45
+ # and the MCP login precheck (mcp_server._login_precheck, ISS-22), so the precheck
46
+ # asks ThoughtSpot exactly what the build's own login will ask.
47
+ TRUSTED_TOKEN_PATH = "/api/rest/2.0/auth/token/full"
48
+ TS_REQUEST_HEADERS = {"Content-Type": "application/json", "X-Requested-By": "ThoughtSpot"}
49
+
50
+
51
+ def trusted_token_request_body(username: str, secret_key: str) -> dict:
52
+ return {
53
+ "username": username,
54
+ "secret_key": secret_key,
55
+ "validity_time_in_sec": 3600, # 1 hour token
56
+ }
57
+
58
+
59
  def _get_code_version() -> str:
60
  """Return the deployed code version for run lineage/debugging."""
61
  for env_name in ("DEMO_PREP_COMMIT", "SPACE_COMMIT_SHA", "GIT_COMMIT", "COMMIT_SHA"):
 
358
  self.sf_role = get_admin_setting('SNOWFLAKE_ROLE')
359
  self.sf_warehouse = get_admin_setting('SNOWFLAKE_WAREHOUSE')
360
 
361
+ self.headers = dict(TS_REQUEST_HEADERS)
362
+
 
 
 
363
  # Use session to maintain cookies between requests
364
  self.session = requests.Session()
365
  self.session.headers.update(self.headers)
 
426
  """Authenticate with ThoughtSpot using trusted authentication (secret key)"""
427
  self.last_auth_status_code = None
428
  self.last_auth_error = ""
429
+ auth_url = f"{self.base_url}{TRUSTED_TOKEN_PATH}"
430
  max_attempts = max(1, int(os.getenv("TS_AUTH_MAX_ATTEMPTS", "3")))
431
  base_wait_seconds = max(1, int(os.getenv("TS_AUTH_RETRY_WAIT_SECONDS", "5")))
432
 
 
439
 
440
  response = self.session.post(
441
  auth_url,
442
+ json=trusted_token_request_body(self.username, self.secret_key),
 
 
 
 
443
  timeout=60,
444
  )
445
 
 
447
  self.last_auth_status_code = response.status_code
448
 
449
  if response.status_code == 200:
450
+ try:
451
+ result = response.json()
452
+ except ValueError:
453
+ # A 200 that is not JSON is a page, not a token: the
454
+ # cluster's standby / wake-up page (sebe, 2026-10-06), a
455
+ # proxy or an SSO page. Name it β€” the bare parse error
456
+ # ("Expecting value: line 1 column 1") hid the cause.
457
+ # Not retried: none of these clear in seconds.
458
+ self.last_auth_error = non_json_auth_error(
459
+ self.base_url,
460
+ response.status_code,
461
+ response.headers.get("content-type", ""),
462
+ response.text,
463
+ )
464
+ print(f" ❌ {self.last_auth_error}")
465
+ return False
466
  if 'token' in result:
467
  # Use the token as bearer auth
468
  self.session.headers['Authorization'] = f'Bearer {result["token"]}'
 
495
 
496
  return False
497
 
498
+ def auth_failure_message(self) -> str:
499
+ """Why the last trusted-auth login failed, as one actionable sentence:
500
+ the user, the cluster, and the cause (a missing account as a plain
501
+ instruction). See thoughtspot_errors.trusted_auth_failure_message."""
502
+ return trusted_auth_failure_message(
503
+ self.username, self.base_url, self.last_auth_status_code, self.last_auth_error
504
+ )
505
+
506
  def authenticate_oauth(self, timeout: int = 120) -> bool:
507
  """
508
  Authenticate with ThoughtSpot using browser-based SSO (Okta, SAML, etc.)
 
2988
  ts_url=self.base_url,
2989
  username=self.username,
2990
  )
2991
+ # Carry the cause into the run's errors: the bare message is all an
2992
+ # MCP caller saw for a missing account or a cluster in standby
2993
+ # (ISS-22). A missing account is stated as the instruction itself
2994
+ # β€” who, which cluster, what to do (findings triage, E2).
2995
+ raise Exception(self.auth_failure_message())
2996
  auth_time = time.time() - start_time
2997
  log_progress(f"[OK] Auth complete ({auth_time:.1f}s)")
2998
  if _slog:
thoughtspot_errors.py CHANGED
@@ -1,4 +1,6 @@
 
1
  from typing import Tuple
 
2
 
3
 
4
  def friendly_mcp_liveboard_error(raw_error: str) -> Tuple[str, str]:
@@ -76,3 +78,170 @@ def friendly_mcp_liveboard_error(raw_error: str) -> Tuple[str, str]:
76
  f"Raw MCP error: {raw}",
77
  "mcp_generation",
78
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
  from typing import Tuple
3
+ from urllib.parse import urlsplit
4
 
5
 
6
  def friendly_mcp_liveboard_error(raw_error: str) -> Tuple[str, str]:
 
78
  f"Raw MCP error: {raw}",
79
  "mcp_generation",
80
  )
81
+
82
+
83
+ # --- Trusted-auth login responses (ISS-22) -----------------------------------
84
+ #
85
+ # What ThoughtSpot's answer to a trusted-auth token request
86
+ # (POST /api/rest/2.0/auth/token/full) says about a login, so a failure can name
87
+ # its real cause instead of "ThoughtSpot authentication failed". Shapes seen
88
+ # for real:
89
+ # no account HTTP 400, application/json, code 10002, debug
90
+ # 'Invalid parameter values: {"username":"Specified user does not exist."}'
91
+ # (runs 2a1c7306af2a, b58980440961, 1a69f7ab4c7f; fake-user probes
92
+ # of secloud and sebe 2026-10-14)
93
+ # standby HTTP 200, text/html "ThoughtSpot - Start Instance ... Your instance
94
+ # entered standby mode ... Complete the verification below to wake
95
+ # up your instance" (sebe 2026-10-06, runs b22397c2cfb3, c34abed7dddb)
96
+
97
+ # Outcomes that mean the build's own login WILL fail, so refusing up front loses
98
+ # nothing. Every other outcome lets the build go ahead.
99
+ LOGIN_BLOCKING = frozenset({"no_account", "rejected", "standby"})
100
+
101
+ _STANDBY_MARKERS = ("start instance", "standby mode")
102
+
103
+
104
+ def _json_or_none(text):
105
+ try:
106
+ return json.loads(text)
107
+ except (TypeError, ValueError):
108
+ return None
109
+
110
+
111
+ def _host_of_url(url: str) -> str:
112
+ return urlsplit(url if "://" in url else f"https://{url}").hostname or url
113
+
114
+
115
+ def is_standby_page(text: str) -> bool:
116
+ """True when a ThoughtSpot response is the cluster's standby / wake-up page."""
117
+ low = (text or "").lower()
118
+ return any(marker in low for marker in _STANDBY_MARKERS)
119
+
120
+
121
+ def ts_error_reason(text: str) -> str:
122
+ """The reason inside a ThoughtSpot REST v2 error body; "" if `text` is not one.
123
+
124
+ v2 nests it as {"error": {"message": {"debug": {"code": ..., "debug":
125
+ "[\\"<reason>\\"]"}}}} β€” the innermost debug is a JSON-encoded list of
126
+ strings. A body that is an error but not in that shape comes back whole.
127
+ """
128
+ body = _json_or_none(text)
129
+ if not isinstance(body, dict) or not isinstance(body.get("error"), dict):
130
+ return ""
131
+ node = body["error"].get("message")
132
+ while isinstance(node, dict):
133
+ node = node.get("debug", node.get("message"))
134
+ if isinstance(node, str):
135
+ parsed = _json_or_none(node)
136
+ if isinstance(parsed, list):
137
+ return "; ".join(str(item) for item in parsed)
138
+ return node
139
+ return json.dumps(body["error"])[:300]
140
+
141
+
142
+ def classify_trusted_login(status_code: int, content_type: str, text: str) -> Tuple[str, str]:
143
+ """(outcome, reason) for a trusted-auth token response.
144
+
145
+ outcome:
146
+ ok 200 with a token, or 204 (session cookie) β€” the login works
147
+ no_account ThoughtSpot says the user does not exist
148
+ rejected 400/401/403 with a ThoughtSpot error body, any other reason
149
+ standby the cluster served its standby / wake-up page
150
+ transient 429 or 5xx β€” says nothing about the user; worth retrying
151
+ inconclusive anything else (a 4xx that is not ThoughtSpot's, a 200 with
152
+ no token). Not proof the login fails, so not a refusal.
153
+ """
154
+ body = _json_or_none(text)
155
+ shown_type = content_type or "none"
156
+ if body is None and is_standby_page(text):
157
+ return "standby", "the cluster served its 'Start Instance' standby page"
158
+ if status_code == 429 or status_code >= 500:
159
+ return "transient", f"HTTP {status_code}"
160
+ if status_code == 204:
161
+ return "ok", ""
162
+ if status_code == 200:
163
+ if isinstance(body, dict) and "token" in body:
164
+ return "ok", ""
165
+ return "inconclusive", f"HTTP 200 without a token (content-type {shown_type})"
166
+ if status_code in (400, 401, 403) and isinstance(body, dict) and "error" in body:
167
+ reason = ts_error_reason(text)
168
+ if "user does not exist" in (text or "").lower():
169
+ return "no_account", reason
170
+ return "rejected", reason
171
+ return "inconclusive", f"HTTP {status_code} (content-type {shown_type})"
172
+
173
+
174
+ def no_account_message(owner: str, ts_url: str) -> str:
175
+ return (
176
+ f"{owner} has no ThoughtSpot account on {_host_of_url(ts_url)}. Ask a ThoughtSpot "
177
+ f"admin to add them, or pass ts_url for a cluster where they have one."
178
+ )
179
+
180
+
181
+ def standby_message(ts_url: str) -> str:
182
+ return (
183
+ f"{_host_of_url(ts_url)} is in standby. Open {ts_url} in a browser and complete "
184
+ f"the wake-up check, then retry."
185
+ )
186
+
187
+
188
+ def rejected_login_message(owner: str, ts_url: str, status_code: int, reason: str) -> str:
189
+ return (
190
+ f"ThoughtSpot on {_host_of_url(ts_url)} refused a trusted-auth login for {owner} "
191
+ f"(HTTP {status_code}): {reason}"
192
+ )
193
+
194
+
195
+ def trusted_auth_failure_message(username: str, ts_url: str, status_code, body: str) -> str:
196
+ """One sentence for a failed trusted-auth login that a person can act on.
197
+
198
+ Names the user, the cluster and the cause β€” a missing account as the plain
199
+ instruction (no_account_message), a standby cluster as its wake-up
200
+ instruction, anything else as ThoughtSpot's own reason with the HTTP
201
+ status. deploy_all raises this, so the run's errors, the MCP result and
202
+ the Slack "Failed" post all say the same thing instead of the bare
203
+ "ThoughtSpot authentication failed" four SEs retried against for two days
204
+ (findings triage 2026-10-15, E2).
205
+ """
206
+ body = body or ""
207
+ status = int(status_code or 0)
208
+ outcome, reason = classify_trusted_login(status, "", body)
209
+ # The body alone settles the two cases that have a plain instruction, so a
210
+ # caller that lost the HTTP status (or recorded the deployer's own standby
211
+ # sentence instead of the page) still gets the right answer.
212
+ shown = f" (HTTP {status})" if status else ""
213
+ if outcome == "no_account" or "user does not exist" in body.lower():
214
+ # The instruction first β€” who, which cluster, what to do β€” then
215
+ # ThoughtSpot's own words, so the run's errors still grep as an auth
216
+ # failure and still quote the response.
217
+ said = ts_error_reason(body) or " ".join(body.split())[:300]
218
+ http = f", HTTP {status}" if status else ""
219
+ return (
220
+ f"{no_account_message(username, ts_url)} "
221
+ f"(ThoughtSpot authentication failed{http}: {said})"
222
+ )
223
+ if outcome == "standby" or is_standby_page(body) or body == standby_message(ts_url):
224
+ return f"ThoughtSpot authentication failed for {username} on {ts_url}: {standby_message(ts_url)}"
225
+ if outcome in ("rejected", "transient"):
226
+ cause = reason or ts_error_reason(body) or " ".join(body.split())[:300]
227
+ else:
228
+ cause = ts_error_reason(body) or " ".join(body.split())[:300]
229
+ return (
230
+ f"ThoughtSpot authentication failed for {username} on {ts_url}{shown}: "
231
+ f"{cause or 'no detail recorded'}"
232
+ )
233
+
234
+
235
+ def non_json_auth_error(ts_url: str, status_code: int, content_type: str, text: str) -> str:
236
+ """The real cause behind a trusted-auth response that should have been JSON.
237
+
238
+ Replaces the bare parse error ("Expecting value: line 1 column 1 (char 0)"),
239
+ which is all the 2026-10-06 sebe standby runs recorded.
240
+ """
241
+ if is_standby_page(text):
242
+ return standby_message(ts_url)
243
+ snippet = " ".join((text or "").split())[:200]
244
+ return (
245
+ f"ThoughtSpot returned a non-JSON response to the trusted-auth token request "
246
+ f"(HTTP {status_code}, content-type {content_type or 'none'}): {snippet!r}"
247
+ )