mikeboone Claude Fable 5 commited on
Commit
3986a1e
Β·
1 Parent(s): a89fce0

fix(model): galaxy joins preserved, DIM_/FACT_ naming banned, keys hidden, agg defaults

Browse files

Four systemic model-quality fixes from the TRI_08190447_DXO review:

- _remove_diamond_joins(): directed-reachability pruning replaces the
undirected spanning tree that capped every model at N-1 joins and
silently dropped legitimate fact->dim joins in multi-fact schemas.
Only true diamonds (two paths between the same pair) and directed
cycles are removed now.
- Naming: blueprint prompt rule 2a + _table_ident() strip DIM_/FACT_
prefixes from table names (even when the user request includes them);
deployer strips them from display names, and collisions resolve to
"Broker Key (Broker Payment)" instead of "Dim Dim Broker Key" /
"fact_Fact ..." / "_2".
- Surrogate/FK key columns get is_hidden: true in model TML (present
for joins, invisible in search/Spotter).
- _determine_column_type(): token-based matching β€” INVOICES_PAID is a
measure again (endswith-'ID' trap), CORPORATE no longer matches RATE,
and RATIO/PCT/DAYS_TO_* default to AVERAGE instead of SUM.

Verified against the exact TRI structure: scratch/verify_fixes.py, 25/25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

demoprep_app/scenario/blueprint.py CHANGED
@@ -213,7 +213,7 @@ class DemoBlueprint:
213
  performance=float(v.get("performance", 1.0) or 1.0),
214
  ))
215
  dimensions.append(BlueprintDimension(
216
- name=_ident(d.get("name", "")),
217
  description=str(d.get("description") or ""),
218
  attribute_columns=[_ident(c) for c in (d.get("attribute_columns") or [])],
219
  values=[v for v in values if v.name],
@@ -248,9 +248,9 @@ class DemoBlueprint:
248
  per=_ident(m.get("per") or m.get("per_entity") or ""),
249
  description=str(m.get("description") or ""),
250
  ))
251
- requested_dims = [_ident(n) for n in (f.get("dimension_names") or dim_names)]
252
  facts.append(FactSpec(
253
- name=_ident(f.get("name", "FACT")),
254
  grain=str(f.get("grain") or ""),
255
  dimension_names=requested_dims,
256
  measures=measures,
@@ -586,6 +586,22 @@ def _ident(text: Any) -> str:
586
  return cleaned[:60]
587
 
588
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  def _looks_generic(name: str) -> bool:
590
  """Detect placeholder values like 'Product 3' or 'Category A'."""
591
  import re
 
213
  performance=float(v.get("performance", 1.0) or 1.0),
214
  ))
215
  dimensions.append(BlueprintDimension(
216
+ name=_table_ident(d.get("name", "")),
217
  description=str(d.get("description") or ""),
218
  attribute_columns=[_ident(c) for c in (d.get("attribute_columns") or [])],
219
  values=[v for v in values if v.name],
 
248
  per=_ident(m.get("per") or m.get("per_entity") or ""),
249
  description=str(m.get("description") or ""),
250
  ))
251
+ requested_dims = [_table_ident(n) for n in (f.get("dimension_names") or dim_names)]
252
  facts.append(FactSpec(
253
+ name=_table_ident(f.get("name", "FACT")),
254
  grain=str(f.get("grain") or ""),
255
  dimension_names=requested_dims,
256
  measures=measures,
 
586
  return cleaned[:60]
587
 
588
 
589
+ def _table_ident(text: Any) -> str:
590
+ """Normalize a TABLE name: SQL-safe UPPER_SNAKE with no DIM_/FACT_ prefix.
591
+
592
+ We never use warehouse-style prefixes β€” BROKER vs LOAD_TRANSACTIONS reads
593
+ fine to data people, and the prefixes cascade into ugly derived column
594
+ names (DIM_BROKER_KEY -> "Dim Broker Key"). If the user's request named
595
+ tables WITH prefixes, directives matching already strips them too, so the
596
+ stripped blueprint still satisfies the directive.
597
+ """
598
+ name = _ident(text)
599
+ for prefix in ("DIM_", "FACT_"):
600
+ if name.startswith(prefix) and len(name) > len(prefix):
601
+ return name[len(prefix):]
602
+ return name
603
+
604
+
605
  def _looks_generic(name: str) -> bool:
606
  """Detect placeholder values like 'Product 3' or 'Category A'."""
607
  import re
demoprep_app/scenario/blueprint_author.py CHANGED
@@ -99,11 +99,11 @@ EXAMPLE_RETAIL = {
99
  # about the company's actual business (selling data products), not the
100
  # generic operations of its industry.
101
  EXAMPLE_DATA_PRODUCTS = {
102
- "notes": "User explicitly requested DIM_CUSTOMER_ACCOUNT, DIM_DATA_PRODUCT, FACT_PRODUCT_USAGE β€” all present verbatim.",
103
  "date": {"grain": "month", "months_of_history": 24, "include_date_dimension": False},
104
  "dimensions": [
105
  {
106
- "name": "DIM_CUSTOMER_ACCOUNT",
107
  "attribute_columns": ["CUSTOMER_TYPE", "CONTRACT_TIER"],
108
  "values": [
109
  {"name": "Meridian Pharma Insights", "attributes": {"CUSTOMER_TYPE": "Pharmaceutical", "CONTRACT_TIER": "Enterprise"}, "performance": 1.5},
@@ -113,7 +113,7 @@ EXAMPLE_DATA_PRODUCTS = {
113
  ],
114
  },
115
  {
116
- "name": "DIM_DATA_PRODUCT",
117
  "attribute_columns": ["PRODUCT_FAMILY"],
118
  "values": [
119
  {"name": "Open Claims Feed", "attributes": {"PRODUCT_FAMILY": "Patient Data"}, "mix_weight": 1.6},
@@ -125,9 +125,9 @@ EXAMPLE_DATA_PRODUCTS = {
125
  ],
126
  "facts": [
127
  {
128
- "name": "FACT_PRODUCT_USAGE",
129
  "grain": "one row per customer per data product per month",
130
- "dimension_names": ["DIM_CUSTOMER_ACCOUNT", "DIM_DATA_PRODUCT"],
131
  "date_column": "MONTH_DATE",
132
  "measures": [
133
  {"name": "QUERIES_RUN", "kind": "base", "distribution": "lognormal", "params": {"mean": 4200, "sigma": 0.7}, "fmt": "int"},
@@ -147,7 +147,7 @@ EXAMPLE_DATA_PRODUCTS = {
147
  "id": "nlq_adoption",
148
  "headline": "Natural-language querying took off after the embedded relaunch",
149
  "insight_type": "trend_break",
150
- "dimension": "DIM_CUSTOMER_ACCOUNT", "dimension_value": "Meridian Pharma Insights",
151
  "measure": "NL_QUESTIONS", "magnitude": 1.6, "window_start": "2026-01-01",
152
  "spotter_question": "Show natural language questions by customer account by month",
153
  "expected_finding": "Meridian's NLQ volume breaks upward from January 2026",
@@ -198,6 +198,10 @@ and grounded in this company's actual world.
198
  2. Star schema: 2-6 dimensions and 1-3 fact tables. Multiple facts may share dimensions
199
  (e.g. a usage fact and a journey fact both keyed to customers and products). Every
200
  measure name must be unique across all facts.
 
 
 
 
201
  3. Base measures get a distribution (lognormal/normal/uniform) with realistic params for a
202
  company of this size. Derived measures get arithmetic formulas over measures in the
203
  same fact.
 
99
  # about the company's actual business (selling data products), not the
100
  # generic operations of its industry.
101
  EXAMPLE_DATA_PRODUCTS = {
102
+ "notes": "User explicitly requested DIM_CUSTOMER_ACCOUNT, DIM_DATA_PRODUCT, FACT_PRODUCT_USAGE β€” all honored, with the DIM_/FACT_ prefixes dropped per our naming convention.",
103
  "date": {"grain": "month", "months_of_history": 24, "include_date_dimension": False},
104
  "dimensions": [
105
  {
106
+ "name": "CUSTOMER_ACCOUNT",
107
  "attribute_columns": ["CUSTOMER_TYPE", "CONTRACT_TIER"],
108
  "values": [
109
  {"name": "Meridian Pharma Insights", "attributes": {"CUSTOMER_TYPE": "Pharmaceutical", "CONTRACT_TIER": "Enterprise"}, "performance": 1.5},
 
113
  ],
114
  },
115
  {
116
+ "name": "DATA_PRODUCT",
117
  "attribute_columns": ["PRODUCT_FAMILY"],
118
  "values": [
119
  {"name": "Open Claims Feed", "attributes": {"PRODUCT_FAMILY": "Patient Data"}, "mix_weight": 1.6},
 
125
  ],
126
  "facts": [
127
  {
128
+ "name": "PRODUCT_USAGE",
129
  "grain": "one row per customer per data product per month",
130
+ "dimension_names": ["CUSTOMER_ACCOUNT", "DATA_PRODUCT"],
131
  "date_column": "MONTH_DATE",
132
  "measures": [
133
  {"name": "QUERIES_RUN", "kind": "base", "distribution": "lognormal", "params": {"mean": 4200, "sigma": 0.7}, "fmt": "int"},
 
147
  "id": "nlq_adoption",
148
  "headline": "Natural-language querying took off after the embedded relaunch",
149
  "insight_type": "trend_break",
150
+ "dimension": "CUSTOMER_ACCOUNT", "dimension_value": "Meridian Pharma Insights",
151
  "measure": "NL_QUESTIONS", "magnitude": 1.6, "window_start": "2026-01-01",
152
  "spotter_question": "Show natural language questions by customer account by month",
153
  "expected_finding": "Meridian's NLQ volume breaks upward from January 2026",
 
198
  2. Star schema: 2-6 dimensions and 1-3 fact tables. Multiple facts may share dimensions
199
  (e.g. a usage fact and a journey fact both keyed to customers and products). Every
200
  measure name must be unique across all facts.
201
+ 2a. Table names are plain UPPER_SNAKE business nouns β€” NEVER prefixed with DIM_ or FACT_.
202
+ BROKER vs LOAD_TRANSACTIONS reads fine to data people; the prefixes cascade into ugly
203
+ derived column names. If the user's request names tables with DIM_/FACT_ prefixes,
204
+ honor the tables but drop the prefixes (DIM_CUSTOMER_ACCOUNT -> CUSTOMER_ACCOUNT).
205
  3. Base measures get a distribution (lognormal/normal/uniform) with realistic params for a
206
  company of this size. Derived measures get arithmetic formulas over measures in the
207
  same fact.
sprint_2026_04.md CHANGED
@@ -256,6 +256,13 @@ should tell. KPI targets, growth trends, and anomaly patterns live in the matrix
256
 
257
  ### Sprint 4 (this sprint)
258
 
 
 
 
 
 
 
 
259
  - [x] **Table-create latency root cause + fix (Aug 10)** β€” ~250s/import was the unscoped TS connection scanning all ~507 Snowflake DBs visible to SE_ROLE (measured identical on sebe AND secloud; NOT instance load, July SRE docs corrected). Fix: (1) connection TML now scoped via required `database` property (`create_connection_tml`), (2) demos write to monthly-rotating DB `<SNOWFLAKE_DATABASE>_<YYYY_MM>` (`get_demo_database()`/`ensure_demo_database()` in `snowflake_auth.py`) since bloated DEMOBUILD (1,003 schemas) still cost 33s vs 0.7s fresh. Probe: `tests/ts_table_create_deepdive.py` βœ…
260
  - [x] **Perf fix verified + deployed (Aug 10)** β€” app e2e: 655s Grade B, 0 timeouts (pre-fix record: 1,084–2,711s, 12/24 timeouts). Deployed to `hf-test` AND MCP Space `thoughtspot-demoprep/mcp` (`df44611`, rollback `3d735c7`); MCP Tixr rehearsal on new code: PASS, 9.2 min vs 16–20 min. Full research notes: `docs/build_time_deep_dive_2026_08_10.md` βœ…
261
  - [x] **CHECKOUT_* column-eating bug found + fixed (Aug 10)** β€” the CREATE TABLE CHECK-constraint sanitizer (`cdw_connector._sanitize_create_table_statement`) used prefix matching, silently deleting any column named CHECK* (CHECKOUT_INITIATED, CHECKOUT_ATTEMPTS...) while the dataset still carried values β†’ the "random" schema/data-mismatch failures (Cloudscape, Atlas Freight, Tixr 02ec3e7). Fixed with keyword+paren matching, 4 regression tests, proven end-to-end into Snowflake. Deployed to MCP Space (`bd28045`); Tixr rehearsal PASS 10.0 min βœ…
 
256
 
257
  ### Sprint 4 (this sprint)
258
 
259
+ - [x] **Model-quality fixes from TRI_08190447_DXO review (Aug 24)** β€” investigated mani's Triumph model (sebe); found four systemic pipeline defects and fixed all in code (NOT applied to the existing TRI objects β€” that demo stays as-is per boone):
260
+ - **Join spanning-tree bug**: `_remove_diamond_joins()` reduced every model to Nβˆ’1 joins via undirected union-find, silently dropping legitimate factβ†’dim joins in galaxy schemas (TRI lost FACT_BROKER_PAYMENTβ†’DIM_BROKER and FACT_CARRIER_CAPACITYβ†’DIM_LANE β€” payment metrics unsliceable by broker). Rewritten to directed-reachability: only prunes true diamonds (two paths between same pair) and directed cycles; shared conformed dims are kept βœ…
261
+ - **DIM_/FACT_ naming banned end-to-end**: blueprint prompt rule 2a + `_table_ident()` normalization in `blueprint.py` (strips prefixes even when the user request includes them; directives matching already prefix-tolerant); Example B updated. Deployer `_strip_dim_fact_prefix()` strips prefixes from display names; conflict resolution now gives the home table the clean name ("Broker Key") and others a readable suffix ("Broker Key (Broker Payment)") β€” no more "Dim Dim Broker Key" / "fact_Fact …" / "_2" βœ…
262
+ - **Key columns hidden**: surrogate PKs + FKs get `is_hidden: true` in model TML (still present for joins) βœ…
263
+ - **Column classification**: token-based matching in `_determine_column_type()` β€” INVOICES_PAID no longer an ATTRIBUTE (endswith-'ID' trap), CORPORATE/GENERATED no longer match 'RATE'; RATIO/PCT/DAYS_TO_* now AVERAGE instead of SUM βœ…
264
+ - Verified offline against the exact TRI structure: `scratch/verify_fixes.py` β€” 25/25 pass. Repaired TRI TMLs staged in scratch for reference only (`model_tri_fixed.tml.yml`, `lb_*_fixed.tml.yml`), not imported.
265
+
266
  - [x] **Table-create latency root cause + fix (Aug 10)** β€” ~250s/import was the unscoped TS connection scanning all ~507 Snowflake DBs visible to SE_ROLE (measured identical on sebe AND secloud; NOT instance load, July SRE docs corrected). Fix: (1) connection TML now scoped via required `database` property (`create_connection_tml`), (2) demos write to monthly-rotating DB `<SNOWFLAKE_DATABASE>_<YYYY_MM>` (`get_demo_database()`/`ensure_demo_database()` in `snowflake_auth.py`) since bloated DEMOBUILD (1,003 schemas) still cost 33s vs 0.7s fresh. Probe: `tests/ts_table_create_deepdive.py` βœ…
267
  - [x] **Perf fix verified + deployed (Aug 10)** β€” app e2e: 655s Grade B, 0 timeouts (pre-fix record: 1,084–2,711s, 12/24 timeouts). Deployed to `hf-test` AND MCP Space `thoughtspot-demoprep/mcp` (`df44611`, rollback `3d735c7`); MCP Tixr rehearsal on new code: PASS, 9.2 min vs 16–20 min. Full research notes: `docs/build_time_deep_dive_2026_08_10.md` βœ…
268
  - [x] **CHECKOUT_* column-eating bug found + fixed (Aug 10)** β€” the CREATE TABLE CHECK-constraint sanitizer (`cdw_connector._sanitize_create_table_statement`) used prefix matching, silently deleting any column named CHECK* (CHECKOUT_INITIATED, CHECKOUT_ATTEMPTS...) while the dataset still carried values β†’ the "random" schema/data-mismatch failures (Cloudscape, Atlas Freight, Tixr 02ec3e7). Fixed with keyword+paren matching, 4 regression tests, proven end-to-end into Snowflake. Deployed to MCP Space (`bd28045`); Tixr rehearsal PASS 10.0 min βœ…
thoughtspot_deployer.py CHANGED
@@ -133,6 +133,19 @@ def _to_snake_case(name: str) -> str:
133
  return _apply_naming_style(name, "snake_case")
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def _infer_liveboard_context_from_custom_request(text: str) -> Tuple[str, str]:
137
  """Infer a coarse vertical/function label for custom liveboard question generation."""
138
  normalized = (text or "").lower()
@@ -1236,23 +1249,19 @@ class ThoughtSpotDeployer:
1236
  original_name=original_col_name
1237
  )
1238
 
1239
- # If the display name is still used, make it unique with table prefixes
 
1240
  original_display_name = display_name
1241
- counter = 1
1242
  while display_name.lower() in used_display_names:
1243
- # Use table prefix for all tables consistently
1244
- if counter == 1:
1245
- # Generate consistent short prefix from table name
1246
- if len(table_name_upper) <= 4:
1247
- prefix = table_name_upper.lower()
1248
- else:
1249
- prefix = table_name_upper[:4].lower()
1250
- # Use snake_case: prefix_name
1251
- display_name = f"{prefix}_{original_display_name}"
1252
  else:
1253
- # Fallback: add number
1254
- display_name = f"{original_display_name}_{counter}"
1255
- counter += 1
1256
 
1257
  used_display_names.add(display_name.lower())
1258
 
@@ -1393,56 +1402,39 @@ class ThoughtSpotDeployer:
1393
 
1394
  # Add columns with proper global conflict resolution (same as working version)
1395
  used_display_names = set()
1396
-
 
 
 
 
 
 
 
1397
  for table_name, columns in tables.items():
1398
  table_name_upper = table_name.upper()
1399
  for col in columns:
1400
  col_name = col['name'].upper()
1401
  original_col_name = col.get('original_name', col['name']) # Use original casing for display
1402
 
1403
- # NOTE: We used to skip FK/PK columns, but ThoughtSpot requires them for joins
1404
- # Even though users don't search "customer 23455", the join columns must be present
1405
- # in the model's columns section for the joins to work properly.
1406
- #
1407
- # SKIP foreign key columns - they're join keys, not analytics columns
1408
- # if self._is_foreign_key_column(col_name, table_name_upper, foreign_keys):
1409
- # print(f" ⏭️ Skipping FK column: {table_name_upper}.{col_name}")
1410
- # continue
1411
- #
1412
- # SKIP surrogate primary keys (numeric IDs) - nobody searches "customer 23455"
1413
- # if self._is_surrogate_primary_key(col, col_name):
1414
- # print(f" ⏭️ Skipping surrogate PK: {table_name_upper}.{col_name}")
1415
- # continue
1416
-
1417
  # Start with basic conflict resolution
1418
  display_name = self._resolve_column_name_conflict(
1419
  col_name, table_name_upper, column_name_counts,
1420
  original_name=original_col_name
1421
  )
1422
 
1423
- # If the display name is still used, make it unique with table prefixes
 
1424
  original_display_name = display_name
1425
- counter = 1
1426
  while display_name.lower() in used_display_names:
1427
- # Use snake_case: prefix_name
1428
- if counter == 1:
1429
- if table_name_upper == 'CUSTOMERS':
1430
- display_name = f"cust_{original_display_name}"
1431
- elif table_name_upper == 'PRODUCTS':
1432
- display_name = f"prod_{original_display_name}"
1433
- elif table_name_upper == 'ORDERS':
1434
- display_name = f"order_{original_display_name}"
1435
- elif table_name_upper == 'ORDERITEMS':
1436
- display_name = f"item_{original_display_name}"
1437
- elif table_name_upper == 'SALES':
1438
- display_name = f"sale_{original_display_name}"
1439
- elif table_name_upper == 'SALESREPS':
1440
- display_name = f"rep_{original_display_name}"
1441
- else:
1442
- display_name = f"{table_name_upper[:4].lower()}_{original_display_name}"
1443
  else:
1444
- display_name = f"{original_display_name}_{counter}"
1445
- counter += 1
1446
 
1447
  used_display_names.add(display_name.lower())
1448
 
@@ -1461,6 +1453,11 @@ class ThoughtSpotDeployer:
1461
  if aggregation:
1462
  column_def['properties']['aggregation'] = aggregation
1463
 
 
 
 
 
 
1464
  # Add calendar property for DATE columns so ThoughtSpot enables
1465
  # time bucketing (.weekly, .monthly, etc.) on them
1466
  if self._map_data_type(col['type']) == 'DATE':
@@ -1487,12 +1484,19 @@ class ThoughtSpotDeployer:
1487
  return yaml_output
1488
 
1489
  def _remove_diamond_joins(self, model_tables: list):
1490
- """Remove join cycles that ThoughtSpot rejects using a spanning tree.
1491
-
1492
- ThoughtSpot requires exactly ONE path between any two tables in a model.
1493
- Uses Kruskal's algorithm: add edges from highest-priority (fact) tables first,
1494
- skip any edge that would create a cycle. Priority order ensures fact table
1495
- joins are preserved and dimension-to-dimension "snowflake" joins are pruned.
 
 
 
 
 
 
 
1496
  """
1497
  def edge_key(src_name: str, join_def: dict):
1498
  return (
@@ -1508,58 +1512,66 @@ class ThoughtSpotDeployer:
1508
  src_name = t['name']
1509
  for j in t.get('joins', []):
1510
  all_edges.append((src_name, j.get('with'), j, edge_key(src_name, j)))
1511
-
1512
  if not all_edges:
1513
  print(f" βœ… No joins to check for cycles")
1514
  return
1515
-
1516
  out_degree = {}
1517
  for t in model_tables:
1518
  out_degree[t['name']] = len(t.get('joins', []))
1519
-
1520
  in_degree = {t['name']: 0 for t in model_tables}
1521
  for src, dst, _, _ in all_edges:
1522
  in_degree[dst] = in_degree.get(dst, 0) + 1
1523
-
 
 
 
1524
  all_edges.sort(key=lambda e: (-out_degree.get(e[0], 0), -in_degree.get(e[1], 0), e[0], e[1]))
1525
-
1526
- parent = {t['name']: t['name'] for t in model_tables}
1527
- def find(x):
1528
- while parent[x] != x:
1529
- parent[x] = parent[parent[x]]
1530
- x = parent[x]
1531
- return x
1532
- def union(a, b):
1533
- ra, rb = find(a), find(b)
1534
- if ra == rb:
1535
- return False
1536
- parent[ra] = rb
1537
- return True
1538
-
1539
  kept_edge_keys = set()
1540
  removed = []
1541
  for src, dst, join_def, e_key in all_edges:
1542
- if union(src, dst):
1543
- kept_edge_keys.add(e_key)
1544
- else:
1545
  removed.append(f"{src}->{dst} ({join_def.get('on', '')})")
1546
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1547
  for t in model_tables:
1548
  if 'joins' not in t:
1549
  continue
1550
  src_name = t['name']
1551
  t['joins'] = [j for j in t['joins'] if edge_key(src_name, j) in kept_edge_keys]
1552
-
1553
  for t in model_tables:
1554
  if 'joins' in t and not t['joins']:
1555
  del t['joins']
1556
-
1557
  if removed:
1558
- print(f" πŸ”Ά Removed {len(removed)} redundant joins (cycle prevention):")
1559
  for r in removed:
1560
  print(f" - {r}")
1561
  else:
1562
- print(f" βœ… No join cycles detected")
1563
 
1564
  def _generate_constraint_id(self) -> str:
1565
  """Generate a constraint ID similar to ThoughtSpot's system constraints"""
@@ -1619,33 +1631,26 @@ class ThoughtSpotDeployer:
1619
  column_name_counts: Dict tracking column name occurrences
1620
  original_name: Original casing of column name (for proper camelCase detection)
1621
  """
1622
- # Use original name if provided (preserves camelCase boundaries)
1623
- name_for_styling = original_name if original_name else col_name
1624
-
 
1625
  # Apply configured naming style
1626
  styled_name = _apply_naming_style(name_for_styling, self.column_naming_style)
1627
-
1628
  if len(column_name_counts.get(col_name, [])) <= 1:
1629
  # No conflict - use styled name directly
1630
  return styled_name
1631
-
1632
- # For conflicts, generate prefix dynamically from table name
1633
- if len(table_name) <= 4:
1634
- prefix = table_name.lower()
1635
- elif 'sales' in table_name.lower() and 'rep' in table_name.lower():
1636
- prefix = 'rep' # Special case for readability
1637
- elif 'customer' in table_name.lower():
1638
- prefix = 'cust' # Common abbreviation
1639
- elif 'product' in table_name.lower():
1640
- prefix = 'prod' # Common abbreviation
1641
- elif 'order' in table_name.lower():
1642
- prefix = 'order' # Common abbreviation
1643
- else:
1644
- prefix = table_name[:4].lower() # First 4 characters
1645
-
1646
- # Apply naming style to prefix + name combination
1647
- prefixed_name = f"{prefix}_{styled_name}" if styled_name else prefix
1648
- return _apply_naming_style(prefixed_name, self.column_naming_style)
1649
 
1650
  def _get_table_prefix(self, table_name: str) -> str:
1651
  """Get appropriate prefix for table to avoid column conflicts"""
@@ -1680,18 +1685,28 @@ class ThoughtSpotDeployer:
1680
 
1681
  # Numeric types should be measures (unless they're IDs or keys)
1682
  if base_type in ['NUMBER', 'DECIMAL', 'FLOAT', 'DOUBLE', 'INT', 'INTEGER', 'BIGINT']:
1683
- # Skip ID/KEY columns - they're join keys, not analytics columns
1684
- if col_upper.endswith('ID') or col_upper.endswith('KEY') or col_upper.endswith('_CODE'):
 
 
 
 
1685
  return 'ATTRIBUTE', None
1686
-
1687
- # All other numeric columns are measures
1688
- # Determine aggregation based on column name patterns
1689
- if any(word in col_upper for word in ['QUANTITY', 'QTY', 'COUNT', 'SOLD']):
 
 
 
 
 
 
 
 
1690
  return 'MEASURE', 'SUM'
1691
- elif any(word in col_upper for word in ['PRICE', 'COST', 'REVENUE', 'AMOUNT', 'TOTAL', 'PROFIT', 'DISCOUNT', 'SHIPPING', 'TAX']):
1692
  return 'MEASURE', 'SUM'
1693
- elif any(word in col_upper for word in ['RATING', 'SCORE', 'MARGIN', 'PERCENT', 'RATE']):
1694
- return 'MEASURE', 'AVERAGE'
1695
  else:
1696
  # Default: numeric = measure with SUM
1697
  return 'MEASURE', 'SUM'
 
133
  return _apply_naming_style(name, "snake_case")
134
 
135
 
136
+ def _strip_dim_fact_prefix(name: str) -> str:
137
+ """Drop a leading DIM_/FACT_ token from a physical name for display purposes.
138
+
139
+ We never surface warehouse-style prefixes in ThoughtSpot column names:
140
+ DIM_BROKER_KEY -> BROKER_KEY, FACT_LOAD_TRANSACTION_KEY -> LOAD_TRANSACTION_KEY.
141
+ """
142
+ upper = (name or "").upper()
143
+ for prefix in ("DIM_", "FACT_"):
144
+ if upper.startswith(prefix) and len(name) > len(prefix):
145
+ return name[len(prefix):]
146
+ return name
147
+
148
+
149
  def _infer_liveboard_context_from_custom_request(text: str) -> Tuple[str, str]:
150
  """Infer a coarse vertical/function label for custom liveboard question generation."""
151
  normalized = (text or "").lower()
 
1249
  original_name=original_col_name
1250
  )
1251
 
1252
+ # If the display name is still used, disambiguate with a readable
1253
+ # "(Table)" suffix β€” never a table-name prefix
1254
  original_display_name = display_name
1255
+ counter = 2
1256
  while display_name.lower() in used_display_names:
1257
+ if display_name == original_display_name:
1258
+ table_label = _apply_naming_style(
1259
+ _strip_dim_fact_prefix(table_name_upper), self.column_naming_style
1260
+ ) or table_name_upper
1261
+ display_name = f"{original_display_name} ({table_label})"
 
 
 
 
1262
  else:
1263
+ display_name = f"{original_display_name} {counter}"
1264
+ counter += 1
 
1265
 
1266
  used_display_names.add(display_name.lower())
1267
 
 
1402
 
1403
  # Add columns with proper global conflict resolution (same as working version)
1404
  used_display_names = set()
1405
+
1406
+ # Key columns (surrogate PKs and FKs) must be IN the model for joins to
1407
+ # work, but they're join plumbing, not demo content β€” mark them hidden.
1408
+ fk_columns = set()
1409
+ for fk in foreign_keys or []:
1410
+ fk_columns.add((fk.get('from_table', '').upper(), fk.get('from_column', '').upper()))
1411
+ fk_columns.add((fk.get('to_table', '').upper(), fk.get('to_column', '').upper()))
1412
+
1413
  for table_name, columns in tables.items():
1414
  table_name_upper = table_name.upper()
1415
  for col in columns:
1416
  col_name = col['name'].upper()
1417
  original_col_name = col.get('original_name', col['name']) # Use original casing for display
1418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1419
  # Start with basic conflict resolution
1420
  display_name = self._resolve_column_name_conflict(
1421
  col_name, table_name_upper, column_name_counts,
1422
  original_name=original_col_name
1423
  )
1424
 
1425
+ # If the display name is still used, disambiguate with a readable
1426
+ # "(Table)" suffix β€” never a table-name prefix
1427
  original_display_name = display_name
1428
+ counter = 2
1429
  while display_name.lower() in used_display_names:
1430
+ if display_name == original_display_name:
1431
+ table_label = _apply_naming_style(
1432
+ _strip_dim_fact_prefix(table_name_upper), self.column_naming_style
1433
+ ) or table_name_upper
1434
+ display_name = f"{original_display_name} ({table_label})"
 
 
 
 
 
 
 
 
 
 
 
1435
  else:
1436
+ display_name = f"{original_display_name} {counter}"
1437
+ counter += 1
1438
 
1439
  used_display_names.add(display_name.lower())
1440
 
 
1453
  if aggregation:
1454
  column_def['properties']['aggregation'] = aggregation
1455
 
1456
+ # Hide surrogate/foreign key columns β€” required for joins,
1457
+ # but nobody demos "broker key 23455"
1458
+ if col_name.endswith('_KEY') or (table_name_upper, col_name) in fk_columns:
1459
+ column_def['properties']['is_hidden'] = True
1460
+
1461
  # Add calendar property for DATE columns so ThoughtSpot enables
1462
  # time bucketing (.weekly, .monthly, etc.) on them
1463
  if self._map_data_type(col['type']) == 'DATE':
 
1484
  return yaml_output
1485
 
1486
  def _remove_diamond_joins(self, model_tables: list):
1487
+ """Remove ONLY joins that create a second directed path between two tables.
1488
+
1489
+ The old implementation reduced the join graph to an undirected spanning
1490
+ tree (max N-1 joins). That silently broke galaxy schemas: two facts
1491
+ sharing conformed dimensions is standard and ThoughtSpot supports it,
1492
+ but the union-find pass saw an undirected cycle and dropped legitimate
1493
+ fact->dim joins (e.g. FACT_BROKER_PAYMENT lost its DIM_BROKER join in
1494
+ the TRI build, making every payment metric unsliceable by broker).
1495
+
1496
+ What ThoughtSpot actually rejects is ambiguity: two DIRECTED join paths
1497
+ from one table to another (a diamond, e.g. F->D1->X and F->D2->X) or a
1498
+ directed cycle. Multiple facts pointing at one shared dim is fine β€”
1499
+ no table ends up with two routes to any other table.
1500
  """
1501
  def edge_key(src_name: str, join_def: dict):
1502
  return (
 
1512
  src_name = t['name']
1513
  for j in t.get('joins', []):
1514
  all_edges.append((src_name, j.get('with'), j, edge_key(src_name, j)))
1515
+
1516
  if not all_edges:
1517
  print(f" βœ… No joins to check for cycles")
1518
  return
1519
+
1520
  out_degree = {}
1521
  for t in model_tables:
1522
  out_degree[t['name']] = len(t.get('joins', []))
1523
+
1524
  in_degree = {t['name']: 0 for t in model_tables}
1525
  for src, dst, _, _ in all_edges:
1526
  in_degree[dst] = in_degree.get(dst, 0) + 1
1527
+
1528
+ # Priority order: when a genuine diamond has to be broken, the
1529
+ # fact-side join (high out-degree source) survives and the
1530
+ # dim-to-dim snowflake edge is the one pruned.
1531
  all_edges.sort(key=lambda e: (-out_degree.get(e[0], 0), -in_degree.get(e[1], 0), e[0], e[1]))
1532
+
1533
+ nodes = {t['name'] for t in model_tables}
1534
+ reach = {n: set() for n in nodes} # nodes reachable from n via kept joins
1535
+ parents = {n: set() for n in nodes} # nodes that can reach n via kept joins
1536
+
 
 
 
 
 
 
 
 
 
1537
  kept_edge_keys = set()
1538
  removed = []
1539
  for src, dst, join_def, e_key in all_edges:
1540
+ if src not in nodes or dst not in nodes or src == dst:
 
 
1541
  removed.append(f"{src}->{dst} ({join_def.get('on', '')})")
1542
+ continue
1543
+ sources = {src} | parents[src]
1544
+ targets = {dst} | reach[dst]
1545
+ # Directed cycle: something downstream of dst already reaches src.
1546
+ # Diamond: some ancestor of src already reaches some target β€” the
1547
+ # new edge would give it a second path there.
1548
+ if (sources & targets) or any(
1549
+ t_ in reach[s_] for s_ in sources for t_ in targets
1550
+ ):
1551
+ removed.append(f"{src}->{dst} ({join_def.get('on', '')})")
1552
+ continue
1553
+ kept_edge_keys.add(e_key)
1554
+ for s_ in sources:
1555
+ reach[s_].update(targets)
1556
+ for t_ in targets:
1557
+ parents[t_].update(sources)
1558
+
1559
  for t in model_tables:
1560
  if 'joins' not in t:
1561
  continue
1562
  src_name = t['name']
1563
  t['joins'] = [j for j in t['joins'] if edge_key(src_name, j) in kept_edge_keys]
1564
+
1565
  for t in model_tables:
1566
  if 'joins' in t and not t['joins']:
1567
  del t['joins']
1568
+
1569
  if removed:
1570
+ print(f" πŸ”Ά Removed {len(removed)} joins that created ambiguous paths or cycles:")
1571
  for r in removed:
1572
  print(f" - {r}")
1573
  else:
1574
+ print(f" βœ… No ambiguous join paths detected")
1575
 
1576
  def _generate_constraint_id(self) -> str:
1577
  """Generate a constraint ID similar to ThoughtSpot's system constraints"""
 
1631
  column_name_counts: Dict tracking column name occurrences
1632
  original_name: Original casing of column name (for proper camelCase detection)
1633
  """
1634
+ # Use original name if provided (preserves camelCase boundaries),
1635
+ # and never surface DIM_/FACT_ warehouse prefixes in display names
1636
+ name_for_styling = _strip_dim_fact_prefix(original_name if original_name else col_name)
1637
+
1638
  # Apply configured naming style
1639
  styled_name = _apply_naming_style(name_for_styling, self.column_naming_style)
1640
+
1641
  if len(column_name_counts.get(col_name, [])) <= 1:
1642
  # No conflict - use styled name directly
1643
  return styled_name
1644
+
1645
+ # Cross-table collision (e.g. BROKER_KEY exists on the BROKER dim and as
1646
+ # an FK on fact tables): the column's home table keeps the clean name,
1647
+ # every other table gets a readable "(Table)" suffix.
1648
+ business_table = _strip_dim_fact_prefix(table_name)
1649
+ if _strip_dim_fact_prefix(col_name).upper().startswith(business_table.upper()):
1650
+ return styled_name
1651
+
1652
+ table_label = _apply_naming_style(business_table, self.column_naming_style) or business_table
1653
+ return f"{styled_name} ({table_label})"
 
 
 
 
 
 
 
 
1654
 
1655
  def _get_table_prefix(self, table_name: str) -> str:
1656
  """Get appropriate prefix for table to avoid column conflicts"""
 
1685
 
1686
  # Numeric types should be measures (unless they're IDs or keys)
1687
  if base_type in ['NUMBER', 'DECIMAL', 'FLOAT', 'DOUBLE', 'INT', 'INTEGER', 'BIGINT']:
1688
+ # Skip ID/KEY columns - they're join keys, not analytics columns.
1689
+ # Match whole name tokens, not substrings: a bare endswith('ID')
1690
+ # misclassified INVOICES_PAID (and anything ending PAID/VALID/GRID)
1691
+ # as an attribute.
1692
+ tokens = col_upper.split('_')
1693
+ if tokens[-1] in ('ID', 'KEY', 'CODE') or col_upper in ('ID', 'KEY'):
1694
  return 'ATTRIBUTE', None
1695
+
1696
+ # All other numeric columns are measures.
1697
+ # Aggregation from whole-word tokens (substring matching hit
1698
+ # CORPORATE/GENERATED for 'RATE'). Ratios, rates, percentages and
1699
+ # per-row durations must AVERAGE β€” summing a rate is meaningless.
1700
+ token_set = set(tokens)
1701
+ if token_set & {'RATING', 'SCORE', 'MARGIN', 'PERCENT', 'PCT', 'RATE', 'RATIO', 'AVG', 'AVERAGE'}:
1702
+ return 'MEASURE', 'AVERAGE'
1703
+ elif 'DAYS' in token_set and ('TO' in token_set or 'SINCE' in token_set):
1704
+ # DAYS_TO_PAY / DAYS_SINCE_X are per-row durations, not additive
1705
+ return 'MEASURE', 'AVERAGE'
1706
+ elif token_set & {'QUANTITY', 'QTY', 'COUNT', 'SOLD'}:
1707
  return 'MEASURE', 'SUM'
1708
+ elif token_set & {'PRICE', 'COST', 'REVENUE', 'AMOUNT', 'TOTAL', 'PROFIT', 'DISCOUNT', 'SHIPPING', 'TAX'}:
1709
  return 'MEASURE', 'SUM'
 
 
1710
  else:
1711
  # Default: numeric = measure with SUM
1712
  return 'MEASURE', 'SUM'