Spaces:
Running
Running
Merge remote-tracking branch 'hf-mcp-test/main' into fix/blueprint-llm-timeout
Browse files- chat_interface.py +23 -7
- demo_builder_class.py +3 -1
- demoprep_app/dataset/contracts.py +10 -0
- demoprep_app/dataset/engine.py +68 -11
- demoprep_app/dataset/formula.py +74 -8
- demoprep_app/dataset/measure_bounds.py +117 -11
- demoprep_app/dataset/measure_names.py +162 -9
- demoprep_app/dataset/validator.py +86 -15
- demoprep_app/integrations/snowflake/__init__.py +2 -0
- demoprep_app/integrations/snowflake/quality_gate.py +131 -29
- demoprep_app/pipeline/build_demo.py +161 -28
- demoprep_app/quality/findings.py +48 -0
- demoprep_app/scenario/blueprint.py +356 -32
- demoprep_app/scenario/blueprint_author.py +12 -1
- docs/PLAN_findings_triage_2026_10_15.md +317 -0
- docs/handoff_iss22_login_precheck_2026_10_14.md +122 -0
- mcp_server.py +318 -31
- slack_notifier.py +78 -7
- sprint_2026_04.md +6 -0
- supabase_client.py +68 -6
- tests/test_build_abort_policy.py +437 -0
- tests/test_chained_portal_settings.py +3 -0
- tests/test_deploy_existing_schema.py +157 -0
- tests/test_flat_measure_gate.py +30 -0
- tests/test_gate_severity.py +4 -1
- tests/test_iss22_login_precheck.py +409 -0
- tests/test_measure_bounds.py +222 -2
- tests/test_measure_formula.py +34 -0
- tests/test_portal_url_round_trip.py +10 -2
- tests/test_quality_gate.py +143 -0
- tests/test_tuning_round_findings.py +5 -3
- thoughtspot_deployer.py +54 -14
- thoughtspot_errors.py +169 -0
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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 4015 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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,
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 631 |
-
|
|
|
|
| 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
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 498 |
-
#
|
| 499 |
-
#
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 506 |
-
|
| 507 |
-
0.0, None, "non_negative",
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 179 |
-
f"{check.required_ratio:.2f}
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 436 |
-
|
| 437 |
-
if len(target) <
|
| 438 |
-
return
|
| 439 |
-
|
| 440 |
-
f"too few rows to demo ({len(target)} target / {len(others)} baseline
|
| 441 |
-
f"raise mix_weight for {insight.dimension_value} or
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
"""
|
| 447 |
-
return infer_bound(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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="
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 312 |
-
|
| 313 |
-
The
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
| 317 |
"""
|
| 318 |
-
|
| 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
|
| 388 |
-
"""
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
for insight in blueprint.insights:
|
| 391 |
-
|
|
|
|
| 392 |
continue
|
| 393 |
-
|
| 394 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 266 |
-
inference missed a vocabulary
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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 |
-
|
| 501 |
-
|
| 502 |
-
|
|
|
|
|
|
|
| 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.
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
fixes.append(
|
| 519 |
-
f"
|
| 520 |
-
f"(
|
| 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 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1169 |
-
|
| 1170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 1274 |
return False
|
| 1275 |
for part_name, whole_name in pairs:
|
| 1276 |
part = by_name.get(part_name)
|
| 1277 |
-
if part is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 = ""
|
|
|
|
| 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
|
| 662 |
-
#
|
|
|
|
|
|
|
| 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 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
visible = sorted(
|
| 185 |
-
(f for f in
|
| 186 |
-
|
|
|
|
| 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")
|
| 809 |
"industry": "",
|
| 810 |
-
"use_case": (payload.get("use_case")
|
| 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")
|
| 844 |
"industry": "",
|
| 845 |
-
"use_case": (payload.get("use_case")
|
| 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": (
|
| 1000 |
"industry": "",
|
| 1001 |
-
"use_case": (
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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},
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 =
|
| 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
|
| 175 |
-
the high bound AND on a non-zero low bound
|
| 176 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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}
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|