Skip to content

feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter - #364

Open
djwaldo wants to merge 81 commits into
apache:mainfrom
djwaldo:feat/thoughtspot-converter
Open

feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter#364
djwaldo wants to merge 81 commits into
apache:mainfrom
djwaldo:feat/thoughtspot-converter

Conversation

@djwaldo

@djwaldo djwaldo commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Adds converters/thoughtspot/ — a bidirectional converter between ThoughtSpot's semantic model format (Model TML plus its Table and SQL View documents) and the Ossie semantic model.

Proposed in #285 (consolidating #269); THOUGHTSPOT was added to the Dialect enum in #351.

Scope

Ossie ThoughtSpot
datasets Model model_tables[] + a Table or SQL View document
fields Model columns[] with column_type: ATTRIBUTE
metrics Model columns[] with column_type: MEASURE, always via formulas[]
relationships Model inline joins and Table joins_with[]
custom_extensions[THOUGHTSPOT] everything with no Ossie home

One structural note that shapes the whole design: one Ossie semantic model corresponds to 1 + N TML documents, not one file. The converter reads and writes the set.

Expression handling — the design decision most likely to draw questions

ThoughtSpot's formula language is not SQL. It has its own syntax (concat ( [a] , [b] ), [TABLE::Column] references, { } grouping) which no SQL dialect parses.

A ThoughtSpot formula is carried verbatim under a THOUGHTSPOT dialect entry. A portable ANSI_SQL sibling is added only where the whole expression is a bare column reference — the one shape where portability is certain. No SQL dialect is ever re-rendered into another, in either direction.

That is deliberate, and the reasoning is in converters/thoughtspot/README.md:

  • The specification's own stated default is to pass unknown values through, and dialects[] exists precisely so an untranslatable expression can still travel tagged with the dialect it is valid in.
  • No converter in this repository re-renders an expression across dialects. The Databricks converter carries expressions verbatim under its own vendor dialect and reads that first.
  • Round-trip fidelity is therefore exact rather than best-effort: the return leg reads the verbatim entry.

expressions/catalog.py maps all 146 constructs the Ossie expression language defines to a ThoughtSpot rendering (108 direct, 37 via sql_* passthrough, 1 unmappable) and is used for Ossie → TML. expressions/reverse.py records which ThoughtSpot-native functions compose into a portable Ossie expression; it is present for testing and future use and is not yet wired into the shipped TML → Ossie path — the README says so explicitly rather than leaving it to be inferred.

Reference documentation is generated from the code

converters/thoughtspot/docs/ is produced by tools/generate_reference_docs.py from CATALOG, the reverse inventory, the datatype map and the vendor-payload key classification. A test compares the committed documents byte-for-byte against fresh generator output, so they cannot drift from the implementation.

Testing

804 tests, offline, on Python 3.10–3.14.

  • Both directions, unit-level.
  • Round-trip both ways over two fixture sets, with preservation and translation asserted separately — the return leg reads the verbatim dialect entry, so a round trip alone proves preservation and says nothing about translation quality.
  • The issue log is asserted as a first-class output: every declared loss must be reported before it happens, and nothing may be reported that did not occur.
  • Property-based round trips (hypothesis, test-only) over deliberately awkward identifiers — YAML 1.1 boolean tokens, names colliding only after normalisation, non-ASCII, names containing ::. This found two crashes the example fixtures never produced.
  • Fixtures mirror examples/tpcds_semantic_model.yaml so this converter is comparable to its siblings; both expected documents validate against core-spec/ossie-schema.json and validation/validate.py.

Dependencies

PyYAML>=6.0 and nothing else at runtime. hypothesis and jsonschema are test-only.

Provenance

All TML fixtures and all code are original, authored for this contribution. Nothing derives from ThoughtSpot's thoughtspot_tml library or any other existing converter.

One question for reviewers

The relationship-level mapping and the key-derivation rule admit two readings of whether a ONE_TO_MANY cardinality inverts a relationship's orientation. The implementation keeps from/to structural and flips only the key-derivation candidate, which satisfies validation/validate.py. I would rather have that settled by someone who owns the specification than guess and encode the guess in tests.

Note on history

81 commits, conventional-commit style. Happy to squash to a smaller set of logical commits if that is preferred for review.

Adds ossie_thoughtspot.constants (VENDOR_KEY, DIALECT, FALLBACK_DIALECT,
STASH_VERSION, SPEC_SERIES, DIALECT_IS_REGISTERED) per task-2-brief.md.
VENDOR_KEY and DIALECT are kept as separate names despite sharing a value
today (P6) since they are governed by different upstream processes.

Also adds converters/thoughtspot/.gitignore, copied verbatim from
converters/honeydew/.gitignore, so a local uv build's egg-info directory
can no longer land in a commit (Task 1 had to delete it by hand).
…yYAML controls

Complete YAML11_BOOL_TOKENS (add Y, N, YES, NO, OFF) and add two differential
tests so the dumper's quoting fix and the loader's 1.2-resolver fix each have
a test that fails if that half is removed -- the prior tests only exercised
the loader, and 9/11 dumper-quote assertions passed against plain SafeDumper.
…CII-only limitation

Review findings on 9ad8fe6:
- split_column_ref now raises on a reference containing more than one '::'
  instead of silently mis-splitting (e.g. a table name formatted with '::'
  in it corrupted the table/column boundary). Delimiter/escaping redesign
  is left to Plan C; loud failure is the interim behaviour.
- normalise's ASCII-only behaviour (non-ASCII characters dropped, not
  transliterated) is now documented as a known limitation rather than left
  implicit, with tests pinning current behaviour for accented Latin and a
  CJK-only name so it can't silently regress.
…, add duplicate-key test

Review findings on task 7:
- KD1: a to-one, non-residual relationship with empty to_columns previously
  vanished silently (no key, no issue) because it still "qualified" for the
  seen-building loop but was excluded by its own `if cols` guard, while the
  KD2 loop was gated on seen being non-empty. _qualifies() now requires
  non-empty to_columns, and the KD2 loop reports an empty-to_columns
  relationship unconditionally rather than only when a key was derived.
- Documented KD3 (orientation re-checked downstream by
  converters/databricks) in the module docstring, resolving the dangling
  KD1-KD3 reference.
- Added a test for two qualifying relationships agreeing on the same
  columns (e.g. a dimension joined from two fact tables), which must
  collapse into one unique key and still yield a primary key rather than
  being mistaken for disagreement.
Documents the two conversion directions, the 1+N TML document shape, and the
Ossie apache#351 dialect caveat. The coverage matrix enumerates what the converter
does not carry (L1-L6); L2 (row-level security) is called out as error
severity, naming every affected table, since rls_rules is the primary
mechanism ThoughtSpot customers are actively migrating onto. The Status
section lists the foundations Tasks 1-7 actually shipped (YAML 1.2 codec,
issue reporting, custom_extensions stash, identifier and key derivation).

test_readme.py asserts structure (both directions named, a Coverage matrix
heading with L-numbered rows, the apache#351 reference) rather than prose wording,
per P16/P21, so the matrix cannot quietly disappear without a test noticing.
…wording

Review findings on the Task 8 README:

- Add a Known limitations section documenting identifiers.py's ASCII-only
  normalise() (drops rather than transliterates non-[0-9a-z] characters,
  e.g. Cafe -> caf, Urun -> r_n, CJK-only raises ValueError). This lived only
  in the module docstring; a reader hitting non-English column names would
  find nothing about it in the README. Kept out of the coverage matrix since
  it is a different axis (identifier-derivation correctness, not an
  uncarried TML construct) - the section says so explicitly.
- Tighten the L2 (row-level security) Limitation cell: one error-severity
  issue is raised, its message naming every affected table - not one issue
  per table, matching the TS_RLS_DROPPED pattern in test_issues.py.
pyproject.toml used setuptools + optional-dependencies where all eight
sibling converters use hatchling + PEP 735 dependency-groups, and was
missing license/readme/authors/pytest/uv config. The built wheel was
missing License-Expression, Author-email, Project-URL, and the embedded
README, and leaked pytest/hypothesis into distribution metadata via
Requires-Dist. Converted to the sibling shape and dropped hypothesis
entirely (unused on this branch).

CI ran `pip install -e ".[dev]"` and never touched uv, so the committed
uv.lock had no consumer. Switched to `uv sync` + `uv run pytest` and
widened the version matrix to 3.10-3.14 to match omni (the sibling with
the same requires-python floor). Regenerated uv.lock. SHA-pinned actions
left unchanged.
…contract

keys.py (I1): the KD2 message unconditionally claimed "Ossie validation
will report a to_columns coverage warning" for every disqualified
relationship. That's wrong for an empty to_columns (upstream's schema
requires minItems: 1, so the document fails validation outright before
any coverage check runs — a schema ERROR, not a warning) and often wrong
for residual-predicate joins whose columns already cover the derived key
(the canonical SCD-2 shape). Split the message: the "not a declared key"
half is unconditional, the upstream-prediction half is appended only when
`not any(set(key) <= set(rel.to_columns) for key in seen)` — mirroring
validate.py:159-165 exactly. Empty to_columns gets its own ERROR-severity
issue with a remedy that doesn't say "Expected".

_yaml.py: load() now wraps a parse failure in ConversionError (I4),
matching stash.py's X4 never-a-bare-traceback contract. dump() now passes
allow_unicode=True (I5), so a non-ASCII label round-trips as a literal
character instead of an escaped one — every Ossie document Plans C/D
emit passes through this function.

Minor: keys.py module docstring no longer says databricks' orientation
swap is "silent" (it calls _warn()); documented Relationship's frozen=True
does not imply safe hashability when to_columns is a list; stash.py's X8
comment now states the guid/obj_id/fqn check is top-level only. Added a
test for restore()'s default (no witness_key) shape.

Tests: 107 passing (102 + 5 new: 2 keys.py, 3 _yaml.py; the stash.py
default-shape test replaces no prior assertion so nets to +1 net file
but the count above already reflects all additions).
…ambiguity gap

identifiers.py (R14, revising an earlier ruling): normalise() now applies
Unicode NFKD decomposition before the ASCII lowercase-and-substitute fold.
An earlier ruling treated the ASCII-only behaviour as a stated boundary on
the grounds that transliteration is a product decision — right about
transliteration (Japanese -> romaji), wrong about canonical decomposition,
which is stdlib and needs no policy choice. "Café" -> "cafe", "Ürün" ->
"urun", "Zürich" -> "zurich", "İstanbul" -> "istanbul", "naïve" -> "naive"
now fold correctly; a script with no ASCII decomposition (CJK, Cyrillic)
still raises, and conventional expansions (German "Müller" -> "mueller")
remain an open, separate question. Updated the module/function docstrings,
the README's Known limitations section, and re-pinned the limitation
tests to their new (narrower) expected values.

identifiers.py (M3): split_column_ref's ambiguity guard counted "::" via
str.count, which is non-overlapping — a run of three consecutive colons
(table ending in ':' immediately before the '::' delimiter) counts as one
match and silently mis-split. format_column_ref("ORDERS:", "Col") and
format_column_ref("ORDERS", ":Col") both produce the identical string
"[ORDERS:::Col]" and are genuinely ambiguous; the guard now also rejects
a captured column starting with ':'. Added tests for both origins.

Tests: 112 passing (107 + 5 new: 5 identifiers.py — 2 M3 cases; the R14
limitation tests replace existing pinned cases rather than adding, net
+3 from the expanded parametrize list).
…al ruleset

README.md (I6): "Converts between..." and "Each row raises a structured
ConverterIssue... nothing is dropped silently" both describe behaviour
that does not exist yet — neither conversion direction is implemented,
and no code raises any of L1-L6. Changed to future tense ("will
convert", "is required to raise... once the conversion directions
land"). This is exactly the shape of complaint discussion apache#325 raises
against an unfulfilled README promise.

README.md (I7): the Rules section named "the ThoughtSpot skills
repository" as the normative source for ~30 rule identifiers
(ID1-ID4, X1-X9, KD1-KD3, NM1-NM6, and more) with no URL — unresolvable
from inside this ASF repo, and no sibling converter defers its
normative behaviour to an external vendor-controlled document. Named
the repository (thoughtspot-agent-skills) explicitly, stated it is not
ASF-hosted, acknowledged the vendor-neutrality gap plainly, and recorded
the intent to contribute the mapping tables into this repository rather
than vendoring them now (a larger change needing its own review).

README.md (M9): L2's severity justification was a commercial-trend
argument ("the mechanism customers are actively migrating onto") in an
ASF repo. Restated as the technical property: RLS is unrepresentable in
Ossie core and is security-bearing.

test_packaging.py (M4): the ASF-header test globbed only src/**/*.py and
tests/**/*.py, leaving pyproject.toml, .gitignore, README.md, and the CI
workflow ungated. Added a check for all four, matching on the licence
text itself since each file uses a different comment syntax.

Tests: 113 passing (112 + 1 new).
…ging

The Development section still said `pip install -e ".[dev]"` /
`python -m pytest tests/ -v`, left over from before the switch to
hatchling + PEP 735 dependency-groups. That install now warns the
package has no `dev` extra and skips pytest, so the next line fails
with ModuleNotFoundError. Replace with the uv invocation CI actually
runs.
apache#351 merged 2026-09-01: THOUGHTSPOT is now a registered Dialect
enum member and is in SKIP_SQL_VALIDATION. Flip DIALECT_IS_REGISTERED to
True, rename FALLBACK_DIALECT to PORTABLE_DIALECT to reflect its new role
(emitted alongside THOUGHTSPOT for portable expressions per P8, not instead
of it), and update the README status section and both tests that encoded
the old assumption.
Task 1 of the expression-translation plan (Plan B). Adds Classification,
Variant and Construct (_types.py), an empty CATALOG to be populated across
Tasks 3-7, and spec_construct_names() — an oracle that parses the upstream
core-spec/expression_language.md so a construct added upstream fails this
package's build instead of silently going unsupported.

spec_construct_names() returns 137 names, not the plan's target of 146; see
task-1-report.md for the family-by-family reconciliation and the concrete
gaps (Window's OVER-clause/aggregation prose, a few alias-pair rows). The
mapping document Tasks 3-9 are meant to transcribe from
(ts-ossie-function-mapping.md) does not exist in this repository yet.
spec_construct_names() (137, parsed live from upstream core-spec) and the
mapping document's rule-E1 census (146, one row per construct incl. a few
described only in prose) count by different units. Per coordinator ruling,
keep the strict spec->catalog gate (catches an upstream addition) and add
CONVENTION_DIVERGENCES: the exact 9 constructs the mapping document counts
separately that the spec never gives a discrete table row - verified row by
row against the mapping document (now located at
thoughtspot-agent-skills/docs/ossie/ts-ossie-function-mapping.md, a
different repo, per the coordinator). test_the_two_counts_reconcile pins
137 + 9 == 146 so the two counts cannot drift apart silently.

Corrects this task's own earlier speculation: CEIL/CEILING, TRUNC/TRUNCATE
and TRUE/FALSE are each one row in the mapping document too (not split),
so they are a CATALOG-key spelling question for Tasks 3-8, not divergences.
Documented prominently in catalog.py's new "Spelling" docstring section.
…templates

Review findings on Task 1:

- Spelling docstring omitted a AND b / a OR b (-> "expr1 AND expr2" /
  "expr1 OR expr2", from the Boolean Functions table) and misattributed
  IS DISTINCT FROM / IS NOT DISTINCT FROM to the top-level summary table
  when they actually come from the Null-Safe Comparison code fence. Full
  section rewritten grouped by extractor function and re-audited against
  a live spec_construct_names() run plus the mapping document's literal
  row text, with an explicit rule: the live function output is the
  oracle, never this list or the mapping document's prose.
- Deleted unused _SUPPORTED_LIST_CUE_RE and corrected the docstring claim
  of a "Supported ...:" prose-cue exclusion mechanism that doesn't exist
  - argument-vocabulary bullet lists are simply invisible to
  _extract_tables(), which only reads pipe-prefixed lines.
- Construct.__post_init__ now rejects a DIRECT/PASSTHROUGH row with no
  template, so a family task can't add one that passes validation and
  the coverage gate but fails only when something tries to emit it.
  New tests/expressions/test_types.py covers all four branches.
…mappable

Adds emit_direct/emit_passthrough/emit_unmappable — one renderer per
Classification, built before the family tasks (3-8) populate CATALOG, so
each family has something real to render into.

Uses the corrected signatures (object_ref required, keyword-only) rather
than the brief's own Interfaces block, which disagreed with its test
snippets on this point:

    emit_direct(construct, args) -> str
    emit_passthrough(construct, args, log, *, object_ref, has_parameter=False,
                      partition_column=None) -> str
    emit_unmappable(construct, log, *, object_ref) -> None

- E2/E7: emit_direct substitutes positionally via str.format and rejects
  an argument-count mismatch rather than tolerating it.
- E4/E7/E12: emit_passthrough renders the SQL body as a quoted template
  string (never substituting args into it — ThoughtSpot resolves the
  placeholders itself), always raises a WARNING issue naming the function
  and the object, and refuses (has_parameter=True) a call that would
  carry a runtime ThoughtSpot parameter (E9).
- E8: an optional partition_column wraps the passthrough call in
  group_aggregate ( <passthrough> , query_groups ( ) + { <col> } ,
  query_filters ( ) ), guaranteeing the partition column reaches GROUP BY.
- E12: emit_unmappable raises an ERROR issue naming the construct and
  returns nothing — never a silent drop.

121 passed + 2 xfailed -> 132 passed + 2 xfailed (11 new tests), verified
on the Python 3.10 floor and 3.13.
… convention

Review finding (Important): the partition_column kwarg was correct in shape
but nothing cross-checked it against the template, so a family task (3-8)
forgetting to pass it on a PARTITION BY row would silently emit an unwrapped
pass-through that is only sometimes correct — a silent wrong answer, not an
error. emit_passthrough now checks "partition by" in the template
(case-insensitive) against partition_column in both directions and raises
ValueError naming the construct on either mismatch. Two new tests, one per
direction.

Review finding (Minor): pinned that emit_passthrough's has_parameter refusal
never also logs a misleading WARNING, via an explicit
assert log.as_dicts() == [] in the existing E9 test.

132 passed + 2 xfailed -> 134 passed + 2 xfailed (2 new tests), verified on
the Python 3.10 floor and 3.13. Both xfails confirmed still xfailed (-rxX),
not xpassed.
Review follow-up: the guard was a plain substring match on "partition by",
so PARTITION  BY (two spaces) or a newline between the words silently made
carries_partition_by False -- defeating the guard in both directions (a
correctly-omitted partition_column leaves the row unwrapped; a correctly-
supplied one trips the opposite check as a false blocker). Switched to
re.search(r"partition\s+by", ..., re.IGNORECASE) and added a test with
irregular internal whitespace that fails against the old substring check
and passes against the regex.

134 passed + 2 xfailed -> 135 passed + 2 xfailed (1 new test), verified on
the Python 3.10 floor and 3.13. Both xfails confirmed still xfailed.
Construct.__post_init__ now rejects a PASSTHROUGH row whose template already
contains its own variant call (e.g. 'sql_string_op ( "LOWER({0})" , {0} )'
instead of the bare 'LOWER({0})'). emit_passthrough builds the
variant(...) wrapper itself, so a template that already contains it would
double-wrap at emission time - a bug that reads as fine in a static glance at
the catalog and is wrong the moment it runs. Task 5 caught exactly this in its
own first draft; this convention had been written down nowhere, so make it
impossible instead of merely discouraged before two more families land.

Also fixes the pre-existing test_types.py example that (accidentally)
exercised the double-wrapped form and asserted it was valid.
djwaldo and others added 29 commits September 7, 2026 18:29
…olumn aggregations

Adds convert_metric to tml_to_ossie.py: a column_id + aggregation metric becomes
AGG(dataset.field); a scalar formula_id + aggregation composes into AGG(<scalar
expr>); an aggregate formula_id (sum(...), etc.) treats the column aggregation as
the documented no-op and carries the verbatim expr unchanged. Aggregate detection
reads ThoughtSpot's native call names off the same expression catalog used to
compose the wrapped rendering, so the two cannot drift apart. Metrics have no
label, so a display name that normalises differently is stashed as tml_name.
Records shape (column_aggregation | scalar_formula_plus_aggregation | formula)
in the metric's stash alongside tml_name, using the pinned enum spellings, so a
return trip can reconstruct the physical-column and scalar-formula-plus-
aggregation shapes instead of collapsing every metric into one. formula is
omitted rather than written: it is also the reverse direction's own default
when no stash is present, so recording it changes nothing about the
reconstruction while making the payload heavier.
…nd sql_*_aggregate_op

The outer-call-only aggregate check missed constructs the catalog holds beyond
the eight TML aggregation rows: group_aggregate (ThoughtSpot's own performant
grouped-aggregation pattern) and the sql_*_aggregate_op pass-through family, so
a formula built from either composed a second aggregation on top and produced
a wrong number with no warning.

Two-layer fix. (a) Broadened the aggregate-name set with group_aggregate and
every Variant ending in "_aggregate_op" (derived from the enum, not listed by
hand). (b) Added formula.find_call_names, a whole-expression, any-depth call
scanner, and use it (_contains_aggregate_call) instead of the outer-call-only
check before composing — this also catches an aggregate nested inside a scalar
wrapper (round(sum(x), 2)) that no set of names alone could. Discarding a
load-bearing column aggregation because the expression already aggregates now
always logs a warning naming why, whether the aggregate was the expression's
own outer call or nested inside one.

Also: expression_entries and _physical_datatype hardcoded "field" in issue
text and codes even when building a metric. Both now take a kind parameter
("field" by default, so convert_field's behaviour and codes are byte-for-byte
unchanged) and convert_metric passes kind="metric" at every call site.
…is the outer call

An aggregate as a formula's own outer call (sum(...), group_aggregate(...), ...)
plus a redundant column-level aggregation is ThoughtSpot's documented, routine
no-op -- the previous warning fired on that common, correct shape and would
have trained readers to ignore the issue log. Narrowed to warn only when the
outer call is not itself an aggregate but one is nested inside it
(round(sum(x), 2)), which is the one shape a reader might expect composition
and not get. Added _outer_call_is_aggregate for the (silent) outer-call check;
_contains_aggregate_call is now only consulted once that check is negative.

Strengthened the existing no-op regression test to assert on the issue log
(it previously asserted only on the expression), and added explicit coverage
for the boundary: sum/average/unique count/group_aggregate as the outer call
produce no warning; round(sum(x),2) and a scalar-wrapped group_aggregate each
produce exactly one; a genuinely scalar formula still composes.

Also corrects find_call_names' docstring and its test's comment, which
overstated what the function does: names that are also operator keywords
(not, if) are excluded even though both are real catalog functions, and the
keyword handling is weaker than split_call's (only a leading run is stripped,
so a keyword after a genuine word is not caught). No behaviour change.
…sh and entry point

Adds the assembler that ties Tasks 1-5 together: datasets built from
model_tables[] + Table/SQL-View documents, the cross-model resolver
convert_field/convert_metric take as `resolve`, joins converted to
relationships (inline and referencing shapes) with KD1 key derivation
(including the ONE_TO_MANY orientation flip), the model/dataset/relationship
custom_extensions[THOUGHTSPOT] stash, and the public convert() entry point
returning OssieConversion(model, issues).

A malformed reference (an ambiguous column_id or join condition) is caught
per object, logged, and skipped rather than aborting the whole conversion.
…ing them

convert_field/convert_metric never returned a fragment for a column's
ThoughtSpot-only properties (index_type, value_casing, ...), so the
assembler had nothing to merge and they vanished with no issue -- a direct
breach of "nothing dropped silently".

Fixed fail-closed in the assembly, without touching either converter's
signature: stash the complement of the properties keys the converter
actually consumed (column_type, synonyms, ai_context, and aggregation for
metrics), under that object's own column_properties extension key, so a
future ThoughtSpot property is preserved by construction rather than
requiring another enumeration edit.

While re-verifying identity leakage for this change, found that copying an
unconsumed property's value wholesale can carry a nested identity key a
top-level-only guard cannot see (a documented ThoughtSpot shape: a custom
map reference nested inside an otherwise plain property). Extended the
identity check to scan nested values too, dropping and logging rather than
stashing when found.
…ity guard, stash unsurfaced columns

Three defects an independent review found and reproduced:

- A relationship's target dataset was never checked for existence, only its
  source -- a join to a table this model failed to build (missing document,
  bad alias) emitted a Relationship upstream's own validator rejects
  outright, with no issue logged. Now checked the same way the source
  already was: dropped, logged, rest of the model stays valid.

- The nested identity scan added for column properties covered only that
  one caller. Moved the scan into stash.write_stash itself -- the single
  point every stashed payload passes through -- via a new
  stash.find_forbidden_key(value, forbidden=None), so no present or future
  caller (model-scope parameters/filters/column_groups/lesson_plans/
  action_object_associations/constraints/model-level joins_with included)
  can bypass it by nesting identity content instead of putting it at a
  payload's own top level.

- Table columns the Model doesn't surface (by column_id, field or metric
  alike) vanished with no stash and no issue. Now preserved verbatim under
  the dataset's unsurfaced_columns, per the pinned mapping document.

Also: stash.read_stash now rejects an unrecognised custom_extensions shape
version (_v) instead of partially reading it as the current shape.

Behavioural decision: the boundary (write_stash) still raises on any
forbidden key found -- that is what makes the guard impossible to bypass --
but the assembly now catches it (_write_stash_safely), drops the one
contaminated top-level stash field, logs which object and which key, and
keeps converting the rest of the model. The payload content is TML data the
converter did not construct, not a programming error, so it must not abort
an otherwise-fine conversion -- consistent with the column-property path,
which already dropped and logged rather than raising.

Committed the key-derivation edge cases (mixed equality+residual, a
self-join, a top-level OR, MANY_TO_MANY) that were previously only
scratch-verified during development.
…columns[]

A SQL View document's physical columns live under a different key entirely
(sql_view_columns[], bound to a query output alias via sql_output_column),
not a differently-shaped entry under columns[]. Every reader of "this
dataset's physical columns" -- datatype lookup via table_lookup, and the
unsurfaced-column check -- read columns[] unconditionally, so every SQL
View column vanished: no datatype resolved for a surfaced column, and an
unsurfaced one was lost with no stash and no issue. This has been broken
since the assembler was written; nothing exercised a sql_view document
until now.

Fixed via three small, kind-aware helpers (_raw_physical_columns,
_normalize_physical_column, _normalized_physical_columns) so table_lookup
and the unsurfaced-column pass both read the right key for the document
kind they were actually handed, translating a SQL View column's
sql_output_column into the db_column_name-shaped field the datatype lookup
already expects, while preserving the column verbatim (with its real
sql_output_column key) when it lands in unsurfaced_columns for reverse-
direction fidelity.

sql_output_column itself is now also stashed under the dataset's
sql_output_columns (DatasetLevel schema key: field name -> alias), for
every surfaced field on a SQL View -- unconditionally, not only when it
differs from the field's name, because there is no safe way to re-derive a
query output alias the way a table's db_column_name might be guessed at.

Confirmed source is already correct for a SQL View: it is the raw
sql_query string directly, matching the mapping document's row -- no
change needed there.

Also: routed convert_metric's own stash.write_stash call through
_write_stash_safely, so the wrapper's docstring claim ("every stash site in
this module") is true rather than aspirational; the payload is hardcoded
scalars today so this is currently a no-op, but the next author who adds
TML-derived content to it no longer silently reinstates the
abort-the-whole-conversion behavior the wrapper exists to remove.
Adds build_table(dataset, log) -> TmlDocument, the first module of the
Ossie -> TML direction: one Ossie dataset becomes one Table or SQL View
document, so the Model document (a later module) has something to
reference by name.

- db_column_name is always written, even equal to the display name.
- db_column_properties.data_type is always written; a datatype-less
  field infers one rather than omitting the compulsory block.
- Each of the four datatypes whose round trip is lossy (Float, Time,
  DateTimeTz, Opaque) raises an issue naming the loss.
- source splits into db/schema/db_table for a table, or is read as a
  verbatim query for a SQL view; a two- or one-part source that is
  neither shape raises an issue instead of guessing.
- The BOOLEAN/BOOL and DOUBLE/FLOAT warehouse spelling is restored from
  a field's own stash when present, and defaults otherwise.
- A dataset's stashed connection_name, source_parts, table_properties,
  sql_output_columns and unsurfaced_columns are all honoured, with
  staleness checked against the live source where a witness exists.

A round trip against the forward direction (tml_to_ossie.convert) found
a real, previously invisible gap no hand-written fixture had exposed:
the forward direction matches a physical column by its display name
only, so a surfaced column's true db_column_name is never captured
when it differs from that display name. build_table now assumes the
two agree in that case -- correct in the common case -- and reports an
INFO issue for it rather than staying silent, since the assumption can
be wrong.

The datatype map's conditional Time -> DATE_TIME mapping (only when the
underlying column is timestamp-backed) is deliberately left
unimplemented here too: the forward direction never emits Time at all,
so a Time-typed field only ever reaches this module hand-authored, with
no stashed ThoughtSpot column and no other storage-format signal on an
Ossie Field to condition on. VARCHAR is written unconditionally instead,
with the declared loss still reported.

589 tests = 556 baseline + 33 new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…use column, not the Ossie identifier

resolve() built the ANSI_SQL sibling from the Ossie field's own
display-derived identifier ("orders.amount"), not the physical column the
mapping document is explicit about ("the identifier is the physical
column; the display name comes from label/name"). Confident, well-formed
SQL naming a column the warehouse does not have, in every model where a
display name differs from its physical column -- the normal case in any
curated model.

resolve() still uses the model's ATTRIBUTE-surfaced-column index
(attribute_index) to decide WHETHER a reference is one the model actually
surfaces, unchanged; only the value it returns once that gate passes now
looks up the table's own physical column list for the real warehouse
reference (db_column_name for a Table, sql_output_column for a SQL View,
via the existing kind-aware _normalize_physical_column translation). A
computed field's own references resolve through the identical path, since
[TABLE::Column] always names a physical column regardless of what formula
is referencing it.

Also, per the datatype map's own words ("the connection's spelling is
recorded in the field stash's data_type key so the return trip re-emits
the same one"): a physical field/metric's data_type is now stashed under
that documented key whenever it is not the canonical spelling for its
mapped Ossie datatype (BOOL vs BOOLEAN, FLOAT vs DOUBLE) -- never for the
canonical spelling itself, so the empty-payload rule stays reachable.

And: db_column_name, lost because the forward direction matches a
physical column by display name only, is now stashed under a new
field/metric-level db_column_name key when it differs from the display
name (Table-backed columns only -- a SQL View's own sql_output_columns
dataset-level key already covers the same fact). Not in the pinned payload
schema; verified by a real round trip (convert() then build_table()) that
this key is necessary but not yet sufficient on its own: the reverse
direction's _physical_identity prioritises the THOUGHTSPOT bracket
(display name only) and does not yet consult it, so db_column_name still
round-trips as an explicitly logged assumption (TS-FIELD-DB-COLUMN-NAME-ASSUMED)
rather than silently -- closing that consumption gap is reverse-direction
work, out of scope here.
…ree more table-build gaps

- Critical: _physical_identity now reads the field-level stash key the
  forward direction writes for a Table column's true db_column_name and
  prefers it over the THOUGHTSPOT bracket's display name. Previously it
  derived db_column_name from the bracket unconditionally and never
  called read_stash for it, so every surfaced column whose display name
  differs from its warehouse name round-tripped to a Table document
  naming a column that does not exist. The fallback (display name ==
  db_column_name) is now used, and reported, only when the stash
  genuinely carries nothing -- upgraded from INFO to WARNING, since the
  consequence is a column binding that may not exist.
- Dataset-level ai_context has no home in a Table or SQL View document;
  build_table now raises TS-DATASET-AI-CONTEXT-UNSUPPORTED for it
  instead of dropping it with no trace.
- The new db_column_name stash key is now a shared constant
  (FIELD_STASH_DB_COLUMN_NAME in constants.py) imported by both
  directions, so they cannot spell it differently. Every other stash key
  shared between tml_to_ossie.py and ossie_to_thoughtspot.py is still a
  bare string literal independently written on each side -- roughly 15
  keys (source_parts, connection_name, unsurfaced_columns,
  sql_output_columns, table_properties, tml_name, table_name, tml_object,
  data_type, and the relationship/model-scope keys) share the same
  drift exposure. Not fixed here; flagged as a follow-up.
- Kind detection ("does this source look like a query?") no longer uses
  "contains whitespace" as its first test -- that misclassified a quoted
  identifier with an embedded space (SALES.PUBLIC."ORDER TABLE") as a
  query, emitting an unimportable sql_view with no issue. A three-part
  dotted identifier (quoted segments included) is now tried first, and
  only a genuine non-identifier shape falls back to the whitespace test.

Verified with a round trip: a five-column table whose display names all
differ from their db_column_names (plus one computed formula and one
unsurfaced column), through tml_to_ossie.convert and back through
build_table, reproduces every column exactly with zero issues raised.

607 tests = 600 baseline + 7 new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…] payload key

Closes the follow-up flagged in 35fa490: every stash-payload key besides
db_column_name (FIELD_STASH_DB_COLUMN_NAME) was a bare string literal typed
independently in tml_to_ossie.py (the writer) and ossie_to_thoughtspot.py
(the reader), or independently retyped at two call sites within
tml_to_ossie.py itself. A one-character spelling disagreement between any
of those pairs makes the reader silently find nothing -- no error, just a
value that stops round-tripping.

Mechanical refactor only, no behaviour change: 34 new constants added to
constants.py, grouped by which Ossie object each key's custom_extensions
entry attaches to (model / dataset / relationship / field-metric), matching
FIELD_STASH_DB_COLUMN_NAME's existing naming and documentation style. Every
occurrence of each key as a stash-payload dict literal is replaced with the
constant, in both converter modules and in every test that hardcodes one
(dataset_stash=/field_stash= fixtures, and read_stash()/_own_stash()
assertions) -- a test hardcoding a key string is the same drift risk, and
the place a wrong key is most likely to be enshrined as correct.

Centralized, by scope:
- Shared: STASH_TML_NAME (model + metric; defensively read at dataset scope)
- Model: unattributed_formulas, unrepresentable_joins, model_properties,
  parameters, filters, column_groups, lesson_plans,
  action_object_associations, constraints, model_joins_with
- Dataset: tml_object, alias, table_name, connection_name, sql_query,
  source_parts (+ its db/schema/db_table sub-keys), unsurfaced_columns,
  sql_output_columns, table_properties
- Relationship (also reused, unchanged, inside unrepresentable_joins[]
  entries): type, cardinality, join_shape, referencing_join, on_expression,
  residual_predicates
- Field/metric: data_type, column_properties (joining db_column_name)
- Metric-only: shape

Deliberately NOT centralized, checked rather than assumed:
- The shape-version key (_v) and the vendor name (VENDOR_KEY) -- already
  handled: _v is encapsulated entirely inside stash.py's own
  read_stash/write_stash pair (one file, adjacent lines), and VENDOR_KEY
  already has a shared constant.
- "from"/"to"/"name"/"expr" inside unrepresentable_joins[]/
  unattributed_formulas[] entries -- each typed at exactly one site today
  (no reader exists yet for either list) and each mirrors an identically-
  named core Ossie/TML schema field (Relationship.from/to,
  formulas[].name/expr), so there is no independent second typing to drift
  against. Left as literals; worth a second look once a Model-scope reader
  is written.
- model_properties' own nested keys (is_bypass_rls, join_progressive,
  spotter_config.is_spotter_enabled) and column_properties' internal
  vocabulary -- copied verbatim from TML's own property names via a single
  typing site (a tuple, or a fail-closed complement), not independently
  retyped anywhere.

Verified by construction: grepping both converter modules for every
identified key name as a bare string literal outside constants.py returns
only genuine TML-schema reads/writes (e.g. join.get("cardinality"),
db_column_properties.data_type on an emitted Table column) -- zero
remaining stash-payload literals. Both modules import the shared constants
from the same constants.py; no key's string value is defined twice.

607 tests, unchanged from baseline -- no test was rewritten to accommodate
this change, only literal keys swapped for the constant that already
carries that value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t invariants

Adds build_model (Ossie semantic_model -> ThoughtSpot Model TML) and
to_thoughtspot_expression (dialect selection mirroring Task 4's own
expression_entries, preferring a verbatim THOUGHTSPOT entry and falling
back to structurally translating a catalog-matched ANSI_SQL expression).

Every formula/metric gets a formulas[] + columns[] pair (R3), a metric is
always a formula and never column_id + aggregation (R4, including the
column_aggregation-shaped arrival case), display names are deduplicated
across columns[]/formulas[] via a case-preserving allocator (R6/ID4),
column_type/synonyms land under properties (R7), is_hidden/
was_auto_generated are never emitted (R8), and a brace-carrying expr is a
block scalar (R9). Formula ids are derived from the normalised display
name so a THOUGHTSPOT-verbatim cross-reference resolves against a formula
this converter itself generates.

Also promotes tml_to_ossie.py's private metric-shape constants to
constants.py (METRIC_SHAPE_*) so the writer and reader share one spelling,
and adds primary_key/unique_keys "unused key" detection to build_model --
a real gap found via round-tripping the construct-mapping document's own
worked example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d ids

build_model regenerates every formula's id from the normalised form of its
own display name (formula_net_amount), but a THOUGHTSPOT-verbatim
cross-reference elsewhere in the model was written against the source
document's own id text ([formula_Net Amount]), which need not match. Left
unrewritten, the reference dangles and ThoughtSpot parses it as search
tokens rather than failing at parse time, so the emitted document fails
import on first attempt.

Fixed by deferring the R9 block-scalar wrap and doing one final pass over
the fully-assembled formulas[] list, once every formula's id is known:
build a normalised-name -> id map from that same list, then rewrite every
embedded [formula_X] reference through it (formula._bracketed_spans, X8's
identity scan already used the same way elsewhere in this package). A
reference matching nothing being built is left as written and logged as
an ERROR rather than silently dropped or guessed. Both the id-minting side
(_formula_id_from) and the reference side (_rewrite_formula_references)
share the same _normalise_or_self fold, so they cannot independently
drift.

Also logs an INFO issue (TS-MODEL-METRIC-AGGREGATION-CONVENTION) when a
metric's surfacing column gets the conventional aggregation property set
over an already-aggregating expression -- Ossie's Metric object has no way
to record whether the source column carried this property, so the value
is always re-derived, and a round trip built for fidelity should surface
that judgment rather than making it silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThoughtSpot accepts only INNER, LEFT_OUTER, RIGHT_OUTER, OUTER for a join
type and rejects both FULL OUTER and FULL_OUTER identically; OUTER is its
own full outer join, so the rename is semantics-preserving and never a
loss. build_model was missing this rename entirely -- a relationship
stashed with type: FULL_OUTER emitted FULL_OUTER verbatim into the
model's inline join, which would fail on import. Applied at both call
sites that write a join type (relationships and unrepresentable_joins[]),
case/whitespace-insensitively so any source spelling normalises the same
way; every other type value passes through unchanged, with no issue
logged for the rename since nothing is lost. This module never writes a
Table joins_with[] entry (build_table has no relationship visibility to
add one there), so that context needs no corresponding fix.

Also closes three minors from the same review:
- _formula_id_from now calls _normalise_or_self instead of repeating its
  try/except, so the id-minting and reference-rewriting sides cannot
  independently drift onto two different fold rules.
- An unrecognised METRIC_STASH_SHAPE value now logs a warning and falls
  back to the documented default, rather than silently falling through.
- Tests import the exported METRIC_SHAPE_* constants instead of
  hardcoding the shape strings they represent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
R8 forbids a generated model from ever carrying is_hidden: true or
was_auto_generated: true -- a hidden column cannot be surfaced again
without a manual edit on the target instance, and re-asserting
was_auto_generated on a column this build did not itself generate would
misrepresent its provenance. Both properties reach the emitted document
through the generic column_properties stash (neither is explicitly
consumed by tml_to_ossie.py, so both fall through to the catch-all that
preserves any property this converter did not otherwise read), and
nothing filtered them back out before this fix.

_drop_never_emit_true_properties removes both from a restored
column_properties payload before it is merged into the emitted
properties dict, at both call sites (fields and metrics). Only a true
value is dropped and logged, at WARNING -- matching this module's other
declared-loss severities, since a column silently losing its visibility
or provenance flag is a real difference the model owner needs to see,
not a benign note. A stashed false is simply omitted with no issue:
false (or absent) is ThoughtSpot's own default for both properties, so
leaving the key out loses nothing. The stash itself is untouched -- it
is the Ossie document's own record of what the source TML held, and
only the emitted TML side ever drops the flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… stale-stash detection

Adds the public ossie_to_thoughtspot.convert() entry point (TmlConversion,
mirroring tml_to_ossie.OssieConversion in reverse) and applies X5's
stash-if-present-and-still-current-else-derive rule via stash.restore(),
rather than plain stash-if-present, to the two constructs whose stash
shadows a live, editable Ossie value:

- A relationship's on_expression (+ residual predicates): witnessed by a
  snapshot of from_columns/to_columns taken when the stash was written.
  A retargeted relationship (from_columns/to_columns edited since) drops
  the stale condition and re-derives the plain equality join instead of
  silently keeping the old narrowing, with an issue recording it.
- A field's stashed warehouse data_type spelling (BOOL/FLOAT): witnessed
  by the Ossie datatype it was recorded against. An edited datatype drops
  the stale spelling and re-derives the canonical one.

Also fixes a real round-trip bug found by driving both public entry
points back to back: a physical column referenced only by a MEASURE
metric's column_id (never by any ATTRIBUTE field) was excluded from
unsurfaced_columns as "already surfaced", but nothing else preserved its
definition -- the reverse direction regenerated a Table missing it while
the metric's own formula still referenced it, a dangling column
reference in an otherwise-valid document. Fixed by keying the
unsurfaced-columns computation on which columns became ATTRIBUTE fields
(attribute_index) rather than on every column_id any Model column names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
find_parameter_refs treated any bracketed name with no `::` as a runtime
parameter, but a formula cross-reference ([formula_Name], R3's id form) is
the same textual shape and a first-class ThoughtSpot construct, not a
parameter — the model builder already recognises exactly this prefix when
rewriting references. The two modules had independent, disagreeing notions
of the convention.

Shares one definition in formula.py (FORMULA_REFERENCE_PREFIX,
is_formula_reference, find_formula_refs), which both tml_to_ossie.py and
ossie_to_thoughtspot.py now read rather than each keeping its own literal.

expression_entries still suppresses the portable ANSI_SQL sibling for a
cross-reference — it is not portable without inlining the referenced
formula, a transformation this converter does not attempt — but now says
so under its own code (TS-EXPR-FORMULA-REFERENCE, INFO) instead of the
false TS-EXPR-PARAM claim. An expression carrying both a cross-reference
and a genuine parameter logs both, each under its own code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…very key

FIELD_STASH_DB_COLUMN_NAME was written with no witness and read with no
currency check: retargeting a field's bracket reference to a different
physical column silently kept the OLD warehouse name, producing a duplicate
display name the emitted document would not import. Fixed with a witness
(the display name db_column_name was recorded against), matching the
pattern already used for FIELD_STASH_DATA_TYPE and a relationship's
on_expression.

Rather than stopping at that one instance, audited every stash key read on
the Ossie -> TML direction and classified each in a new
STASH_KEY_CLASSIFICATION table (constants.py): does it shadow a value
derivable from the live Ossie document (needs a witness/currency check), or
is it information that exists nowhere else (stash-if-present is correct)?
A new test (test_stash_key_classification.py) fails if a key read anywhere
in ossie_to_thoughtspot.py has no entry in the table, and if a
SHADOWS_DERIVABLE key has no witness constant or documented self-check --
so the next key added has to declare an answer rather than default to the
unsafe one.

The audit found two more unwitnessed shadowing keys, both fixed the same
way: STASH_TML_NAME at metric and model scope (a renamed metric/model kept
serving its stale pre-rename display name -- fixed via a self-verifying
check, no separate witness needed, since the stashed name's own normalised
form is the comparison), and DATASET_STASH_TML_OBJECT (a dataset whose
source was rewritten from a query to a table reference, or back, kept the
stale table/sql_view kind -- fixed with a witness on the source string).

Also: a display-name collision resolved by the allocator is now logged
(naming both the original and the allocated name) instead of silent; an
unattributed formula (references span two+ datasets) is now restored fully
surfaced with a normal columns[] entry -- TML never required dataset
attribution for a formula's surfacing entry in the first place, so nothing
stopped this -- rather than re-emitted as an orphan formulas[] entry that
ThoughtSpot's own visibility rule would hide, while the old issue claimed
only column properties were lost; and a parameter or formula cross-reference
used twice in one expression is now named once in its issue, not twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l axis

DATASET_STASH_UNSURFACED_COLUMNS is INFORMATION_ONLY in its per-entry
content (a physical column's warehouse name/type has no Ossie-side
counterpart), but that classification says nothing about whether an entry
still BELONGS: a field added or retargeted onto a column that was
unsurfaced when the stash was written makes that column surfaced now.
Blindly restoring the stale entry alongside the live field's own build
duplicated it -- a duplicate Table/SQL-View column name, which does not
import. Reproduced and fixed: the reverse direction now drops any restored
unsurfaced-column entry whose name collides with a column already built
from a live field, silently -- the column is still present once, so there
is nothing to name in an issue.

The taxonomy could not express this (content-safe, membership-stale), so
it gained a second, orthogonal axis: STASH_KEY_CLASSIFICATION still answers
"can the value disagree with the live document", and the new
STASH_KEYS_WITH_DERIVABLE_MEMBERSHIP frozenset separately answers "can a
list entry be superseded by something the live document now covers".
Checked every other list-shaped INFORMATION_ONLY key against the second
question directly: DATASET_STASH_SQL_OUTPUT_COLUMNS is consulted only as a
per-field dict lookup (never appended as a block, so a stale entry is
simply never looked up, not duplicated) and does not qualify;
MODEL_STASH_UNATTRIBUTED_FORMULAS and MODEL_STASH_UNREPRESENTABLE_JOINS
flow through the shared display-name allocator or tolerate multiple joins
without an import-breaking collision. Extended
test_stash_key_classification.py with a consistency gate (a
membership-derivable key must be classified INFORMATION_ONLY) and verified
by construction that both it and the original completeness gate still fail
closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds tests/fixtures/tpcds/ (5 core datasets, 4 core relationships, 5 core
metrics mirroring examples/tpcds_semantic_model.yaml exactly, plus a SQL
View dataset and two extra relationships/four extra metrics covering the
constructs that have broken during this build: a display name differing
from its db_column_name, a YAML 1.1 boolean-token column name, a
brace-carrying window formula, a formula cross-reference, a
connection-specific BOOL column, a SQL View output alias differing from
its column name, a physical column the Model does not surface, a
non-equality join condition, a composite-key relationship, and one metric
of each of the three TML shapes) and tests/fixtures/minimal/ (the smallest
one-model-plus-N-tables split). Each expected.ossie.yaml was generated by
running tml_to_ossie.convert() over its TML and reviewed by hand, then
also independently checked against validation/validate.py and the
upstream JSON schema.

tests/test_fixtures.py asserts every TML fixture loads, both
expected.ossie.yaml documents validate against core-spec/ossie-schema.json,
converting each fixture set reproduces its expected document exactly, and
the TPC-DS fixture set exercises every construct listed above.

725 -> 750 tests.
Adds `ossie-thoughtspot to-ossie`/`to-tml` (argparse only) and the
`[project.scripts]` entry point, now that `cli:main` exists to point it
at. Issues are always emitted as a JSON array -- to `--issues` when
given, to stderr otherwise -- and never mixed with document output;
exit code is tied to IssueLog.has_errors(), not to the mere presence of
a warning/info issue. `to-tml` writes one file per document via
`tml.dump_document_set` (tables before the model) into an output
directory, with a resolve-then-compare containment check as a second,
independent guard against a document name escaping that directory.
Neither direction overwrites an existing target without `--force`, and
a conflict on any one target refuses the whole run before any file is
written.
…icating a field's description onto its Table column

Ossie -> TML -> Ossie renamed a Table-referenced relationship once its
join round-tripped through TML's nameless inline join syntax, because
the stashed original name was written but never read on the way out.
_join_entry_for_relationship now restores the referencing_join pointer
and the Table's own joins_with[] entry from that stash, with a currency
check against the relationship's live name so a renamed relationship
falls back to the old behaviour instead of restoring a stale reference.

_physical_table_column/_physical_sql_view_column no longer copy a
field's description onto its physical Table column -- every field
reaching those functions is already Model-surfaced, and the Model
columns[] entry is the description's only correct home.
…iers

Adds a Hypothesis-driven Ossie -> TML -> Ossie property suite that draws
dataset and field names from a pool deliberately including YAML 1.1 boolean
tokens, names colliding only after normalise(), NFKD-foldable and
non-foldable non-ASCII, "::"-containing names, whitespace padding,
punctuation-only text, very long text, and the empty string. Each document
also crosses the real YAML 1.2 codec on both legs, not just the pure
conversion functions.

Two real, previously uncaught ValueError crashes were found and fixed:
- ossie_to_thoughtspot.py's _physical_identity/_field_physical_display_name
  let an ambiguous [TABLE::Column] bracket's split_column_ref failure
  propagate out of a document conversion uncaught; now caught, reported as
  TS-FIELD-COLUMN-REF-MALFORMED, and the field is treated as a formula
  rather than crashing the whole conversion.
- tml_to_ossie.py's convert() (the model's own top-level name) and
  _index_attribute_columns() called identifiers.normalise() unguarded on a
  display name with no ASCII alphanumerics to fold onto; now caught, with a
  reported TS-MODEL-NAME-UNNORMALISABLE fallback for the former and a silent
  skip (already re-reported downstream by convert()'s own Phase 3 guard) for
  the latter.

hypothesis is dev-group only (never in [project.dependencies]), matching
converters/databricks' own placement. 793 baseline + 5 targeted regression
tests for the two fixes + 3 property tests = 801 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…verter

Rewrites the converter README to document both directions, the CLI, the
custom_extensions[THOUGHTSPOT] payload, and a dedicated section on what
expression translation does not attempt and why (the specification's own
pass-through default, no converter in this repo re-renders one SQL dialect
into another, and the reference converter behaves the same way).

Cleanup for public ASF review: de-identifies ~18 provenance comments that
named the internal test instance (se-thoughtspot) while keeping the
verification dates and evidence; trims the catalog.py module docstring and
several narrative note= entries toward what a maintainer needs. Removes two
custom_extensions stash keys that were written and never read (sql_query,
which duplicates the live source field; residual_predicates, already fully
contained in the verbatim on_expression) -- the payload shape version is
not bumped, since no reader ever required either key's presence. Verified
pyproject.toml's [project.scripts] entry and hypothesis test extra were
already correct; regenerated uv.lock (no changes). Added the THOUGHTSPOT
row to converters/README.md and core-spec/spec.md's custom_extensions
vendor table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… references from code

The expression mapping, reverse inventory, datatype map and vendor payload
were originally hand-authored design documents. The code now implements that
mapping, so it is the single source of truth: tools/generate_reference_docs.py
reads CATALOG, REVERSE, datatypes.py and constants.py's STASH_KEY_CLASSIFICATION
back out into four committed docs/*.md files, and
tests/test_reference_docs_current.py regenerates on every run and compares
byte-for-byte so a stale doc fails the suite instead of silently drifting.

The generator is dev/tooling only (stdlib + this package's own modules, no
new dependency, not a wheel package, not a console-script entry point) so the
package's only runtime dependency stays PyYAML. test_shipped_references.py
now also scans docs/**/*.md and tools/**/*.py for internal-process language
and unresolvable rule-id citations, same as every other shipped file.
…p the label

The converter's comments, docstrings, and a handful of issue codes cited
short rule identifiers (E1-E13, X1-X9, R1-R11, ID1-ID4, A1-A12, KD1-KD3,
NM1-NM6, G1-G15) from a hand-authored design reference that is not part of
this repository and will not be. Every citation of that shape has been
rewritten in place to state what the rule actually requires -- most were
already fully explained by their surrounding sentence and just needed the
label dropped; a handful (the moving_*/cumulative_* partition loss, LAG's
aggregation/ORDER BY constraints, and write_stash's foreign-vendor
scenario) had real reasoning added back in.

A small number of internal issue codes (`E7-PASSTHROUGH`, `E12-UNMAPPABLE`,
`E10-DAYOFWEEK-BASE`, and 12 siblings in reverse.py/emit.py) also embedded
the citation as data, not prose. These are renamed to the `TS-EXPR-*`
convention every other issue code in this converter already uses -- no
test asserts the old literal values, so this is a safe alignment, not a
behavioural change.

docs/*.md are regenerated from the now-citation-free source via
tools/generate_reference_docs.py. README.md's "Rules" section is rewritten
to reflect that the shipping decision is resolved rather than pending.

The seven now-empty families are removed from
test_shipped_references.py's provisional allowlist, so the guard fails
closed on any of them reappearing; the "I" (invariant) family is
untouched -- resolving it is a separate, unscoped decision. Verified the
guard actually fires by temporarily reintroducing one citation and
confirming the suite failed before reverting it.

804 tests before, 804 after -- no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous cleanup rewrote citations to eight rule-id families
(A/E/G/ID/KD/NM/R/X) in place and removed them from
MAPPING_DOC_RULE_IDS, but left the "I" (invariant) family as an
explicitly provisional exception -- I1/I4/I5 appeared 5 times in
tests/test_keys.py and tests/test_yaml.py.

Those 5 comments already stated the substance of the rule inline, so
the fix is the same as before: drop the label, keep the sentence
(capitalizing the following word where the label led the sentence).

MAPPING_DOC_RULE_IDS is now permanently empty. The name and its two
guard tests (test_allowed_token_sets_do_not_overlap,
test_no_unresolvable_identifier_shaped_tokens) stay -- deleting them
would drop the test count below 804 -- but the surrounding comments no
longer describe anything as provisional or pending, and say plainly
that repopulating the set needs a fresh, reasoned decision, not a
silent addition.

Verified the guard still bites: reintroduced "I5" into
tests/test_yaml.py, confirmed test_no_unresolvable_identifier_shaped_tokens
failed naming that exact token and line, then reverted. A follow-up
sweep -- importing the module and running its own TOKEN_SHAPE_RE
across its own _shipped_files(), minus only ALLOWED_TOKENS -- found no
further unnamed families.

804 tests before and after -- no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jbonofre
jbonofre self-requested a review September 7, 2026 11:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant