Changelog¶
The authoritative changelog lives at the repository root.
Changelog¶
All notable changes to tdfs4ds are documented in this file.
[0.3.6.0] - 2026-10-31¶
New: one clock per data domain, soft clones made for you, run() over a list at one time¶
A data domain can now declare its business calendar once, and the processes of the domain stay in step without
anyone managing cursors (tdfs4ds.data_domain_settings, exported on tdfs4ds). It is an alternative to attaching
a TimeManager per process, which stays fully supported and gives more control (a calendar of its own, hand-made
clones).
set_domain_time_manager(tm, data_domain=None)declares the clock, stored in a new optional tableFS_DATA_DOMAIN_SETTINGS(created atsetup()/connect(), likeFS_PROCESS_SETTINGS; no existing table is altered).get_domain_time_manager,detach_domain_time_manager,domain_processes, andalign_domain_time_managers(dry_run=True)for processes registered before the clock.upload_featuresfinds the TimeManager by itself: it walks the source view's lineage (through intermediate views) and records the manager the view reads. When that is the domain clock, the process gets its own soft clone<process view>_TM(same dates, own cursor) and its view is rewritten to read it, with a log line. New last parametersoft_clone=None(False= keep the clock,True= clone any manager). An explicittime_manager=is paired as in 0.3.5.19, even when it is the clock (no clone:tm.update(i); run(pid)keeps following it).run(pid, time_manager=clock)for a process reading a soft clone of that clock sets the clone to the clock's date for the run, asroll_outdoes. It used to warn (or refuse under'error') and stamp a date the view did not compute at.roll_out(data_domain='X')(orroll_out()fortdfs4ds.DATA_DOMAIN) rolls out every process of the domain, driven by its clock, every clone moved to the same date at each step.run([...]),run(data_domain='X'),run(pid, at='2024-03-31')compute processes at ONE time:at=, else the database clock read once. Each TimeManager view the processes read is swapped for that literal date during the computation and put back on its cursor afterwards, in afinally. No cursor moves, so a refresh "now" after a roll-out is possible without touching the managers. Processes run in dependency order; a failure skips the processes downstream of it; the call returns{pid: status}. A process reading no TimeManager is stored at the same time, with a warning. The swap changes only the value the view selects: it still readsFROM <table> WHERE TIME_ID = n, so lineage to the table of dates holds and an interrupted run is repaired by the next one (TimeManager.restore_cursor()by hand).- Documentation: a soft clone takes its clock's documentation (
PROPAGATED,tdfs4ds.genai.clone_docs), no LLM call per clone; a clock without documentation leaves its clones to the usual path. - Migration:
plan_migrationreads the source domains' clocks (plan["domain_clocks"]) andmigrate_objectsrecreates each clock view over the migrated table of dates, at its cursor, and declares it for the target domain. A time manager that came with the objects is no longer copied again (a copy per clone would split the calendar). - Reporting: the Report's Time drivers lists each domain clock with the processes on and off it; the workflow
assessment adds
process_not_on_domain_clock;get_process_infoaddsDOMAIN_TIME_MANAGER. - Admin app: the feature catalog's process card has a Domain clock row (the clock, and whether the process
reads it, its own soft clone, no TimeManager or another calendar); the dataset builder offers the domain clocks first
in its TimeManager list; the EFS map groups
FS_DATA_DOMAIN_SETTINGSwith the run drivers. Additive: a domain without a clock (or a store without the table) shows exactly what it showed before. Live-verified on efs-dev.
New: the documentation ships with the package, and the app shows it¶
- The MkDocs site (
docs/, built bymkdocs build) was never in the wheel: apip installhad no documentation, and the app's docs server started on a missing folder.setup.pynow copies it into the package astdfs4ds/docs_site(build_pyhook;MANIFEST.ingraftsdocsfor the sdist). Wheel 2.3 MB -> 9.2 MB. - The admin app serves it at
/documentationon its own port (works behind a path prefix and Jupyter's proxy) and shows it in a new rail section, Help › tdfs4ds guide. The separate docs port (TDFS4DS_SERVE_DOCS_PORT) is kept and now finds the packaged copy too. A source checkout keeps serving its owndocs/. tdfs4ds.docs_path(page='index.html'): the local path of a documentation page.
Documentation and docstrings brought up to date¶
- Docstrings:
connect(had none),build_benchmark_html,build_dataset_opt(says which arguments it ignores),generate_on_clause,write_relocation_script; the undocumented parameters ofrun,roll_outandupload_features(and therun_nowvalidation notes, which sat underclose_absent_entities); the 0.3.6.0 functions andsplit_time_manager,unify_time_managers,link_time_managers,time_manager_dependency. upload_featuresguide: the signature block is generated from the function (it showed a*, wrong defaults and lacked a dozen parameters), and rows fortime_manager(missing),soft_clone,primary_index,force_compute,on_failure,check_upstream,grouped,storage_tag.- Configuration page: 20 settings that were not listed (
TIME_MANAGER_CHECK,TIME_MANAGER_DETECT,PROCESS_SQL_VERSIONING, theMCP_*and embedding batch settings, proxies...). docs_src/api/index.md, a static API export last regenerated on 2026-07-13 (itsrun()had 4 parameters), now points to the generated API reference. Broken anchors fixed; the admin UI section of the deployment page lists today's rail.
Changed (announced, each with an off switch)¶
upload_featureswithouttime_manager=records the manager the view reads, even with no domain clock. Before, such a process stayed undeclared until its firstroll_out, sorun(pid)stamped real time while the view computed at the manager's date (the warningwarn_if_undeclaredalready called that out). Several managers read: a warning, nothing recorded.tdfs4ds.TIME_MANAGER_DETECT = False(envTDFS4DS_TIME_MANAGER_DETECT) restores the old behaviour. This changes the valid time of the runs of such processes from real time to the manager's date.roll_outruns the processes of each date in dependency order (from the lineage graph). The sort is stable: a list already in a valid order, or whose processes do not depend on each other, runs exactly as before.order='given'keeps the list's order.
Fixed¶
- Migration of a TimeManager failed every time (
recreate_manager: "tddf must be an instance of tdml.DataFrame"; a pandas frame was passed toload_time_steps). It is now a hard clone (table copied, view at the source's cursor). Seen live; it went unnoticed because the manager's view and table usually come with the migrated objects. - A soft clone of a manager in another database looked for its table in its own database:
update(),_exists(),nb_time_steps,take_ownershipandgenerate_timeline(which also hardcoded<view>_HIDDEN) read the wrong table, androll_outplaced it on another calendar.TimeManager.table_schemanow holds the table's database (read from the view, the registry'sTABLE_DATABASE_NAMEotherwise),manager_indextakes the registry's table database, and the generated parallel script groups processes by table of dates, so soft clones of one manager run in one lockstep group (every clone moved at each date). TimeManager._exists()failed on a database with no table yet (db_list_tablesreturns noTableNamecolumn).
Backward compatibility¶
- With no domain clock, the calls
run(pid)(real time, explicit time, argument, recorded manager),roll_out(plain and with a soft clone) and the generated script make are compared with traces captured from 0.3.5.19 (tests/fixtures/domain_clock_baseline.json): identical. - New parameters are keyword-only and last (
at,data_domain,orderonrun;data_domain,orderonroll_out;soft_cloneonupload_features).run(pid)returns what it returned; the status dict is only for a list or a domain. - Older clients sharing a store ignore
FS_DATA_DOMAIN_SETTINGS; a soft clone is an ordinary manager view to them.
Backward compatibility, live (efs-dev): the same classic workflow (TimeManager attached at registration, a
process registered without one, roll_out, tm.update + run, run(time_manager=), an explicit
FEATURE_STORE_TIME) run by 0.3.5.19 and by 0.3.6.0 in two throwaway stores: catalogs, follow-up rows, stored values
and view DDL identical at every step, except the announced one (the process registered without time_manager= is
paired at registration instead of at its first roll_out). A 0.3.5.19 client on a store where 0.3.6.0 made a domain
clock and clones connects, continues the history with its own roll_out (clones kept in step) and runs run(pid) on
the recorded clone. A migration of a domain with a clock carries it (clock declared in the target, clones on its
table, roll_out(data_domain=) and run(data_domain=) there).
Live-verified on Vantage 20 (efs-dev): domain clock with three processes (one reading another, one with no manager),
detection and soft clones at registration, roll_out(data_domain=) history equal to the expected sums in dependency
order, clones in step, run(data_domain=) at one instant with every clone back on its cursor, run([p], at=), a
clone left on a literal date repaired by the next run and by restore_cursor(), a soft clone in another database
(update, dates, swap and restore). Guarded by tests/test_domain_clock.py.
[0.3.5.19] - 2026-10-30¶
Skills: the remaining verified traps moved into the package¶
The repository's .claude/skills/fs-shared/gotchas.md (written for 0.3.1.141, never shipped, no longer referenced by any skill) is removed; what is still true moved into the package skills, so install_skills() delivers it:
fs-setup: neversetup(if_exists='replace'), which drops the catalogs of the database (every user's features).fs-upload:df=Noneis still required withsql_query=; re-running a process writes nothing for an unchanged (bit-identical) value.fs-inspect: new section Querying the catalogs in SQL (object names with the realFS_FS_prefix,ENTITY_NAMEvsENTITY_ID,FEATURE_VIEWvsVIEW_NAME, joining onFEATURE_NAMES,LIKE ... ESCAPE,TRIM(TableName),ValidStart/ValidEndinstead ofBEGIN(VALIDTIME), noORDER BYinfrom_query, lazy DataFrames).fs-dataset: a dataset is a view, not a copy.td-analytics-workflow:copy_to_sql(if_exists='append')withNaNin a text column.
Outdated points (connection before import, grouped not persisted, unqualified names rejected, DATASET_TYPE snapshot only) were dropped. No code change.
[0.3.5.18] - 2026-10-30¶
Fixed: the consumer agent follows up on what its previous answer listed¶
Seen live on AI Studio (both demos in one store, 7 datasets): after "What datasets are available?", "Is it up to date?" answered "I don't have any features or datasets in our conversation context yet", and "Give me a sample" asked a generic three-way question; with one dataset listed, the freshness answer named a feature ''. And "give me a sample" after a question on one dataset sampled other datasets.
- A follow-up that points at nothing it names ("is it up to date?", "give me a sample", "where does it come from?", French too) right after a listing (dataset inventory, feature summary, search) refers to the listed items: one item is used directly; several are offered as a numbered question, each with the question rewritten ("Is
db.ds_xup to date?"), and the reply number runs it. Also when the intent classification fails on the gateway, which used to send the question to RECAP. - The MCP data query receives the object routing resolved: "give me a sample" about the conversation's dataset reached the MCP agent without the dataset name, which then sampled others. The question now carries it when it does not name it already. The classifier's failure path also applies the remembered dataset to a bare sample request (it returned before reaching that rule).
- Live-verified on AI Studio: freshness and samples chosen from the 7-dataset list, a sample after a freshness answer, a single-dataset list answered directly.
[0.3.5.17] - 2026-10-30¶
Changed: value types a feature cannot have are refused before registration; datasets avoid aggregating types MAX() refuses¶
Measured on Vantage 20: every type goes through the ingestion's temporal MERGE, LOBs included. The limits are comparison and aggregation (tdfs4ds.feature_store.value_types).
- Refused at registration: CLOB, BLOB, JSON and now XML.
upload_featureschecks the source types before writing anything and returnsNonewith the reason and what to do (extract scalar fields in the view). These values cannot be compared (Errors 5689, 3600): an unchanged value cannot be recognised, so every run would open a new period, and a history cannot merge equal periods. Before, CLOB/BLOB/JSON were refused only when the process ran, after it was registered; XML was not refused. - Run time unchanged: CLOB/BLOB/JSON still raise; a process registered earlier with an XML feature keeps running, with a warning.
- Grouped datasets and lag/lead pivots read
MAX(CASE ...), which Teradata refuses on periods (6976), ST_GEOMETRY and other user-defined types, XML, JSON (6945) and LOBs (5664).build_dataset/rebuild_dataset/build_grouped_datasetnow read the storage tables' value types (HELP COLUMN, one per table, only when the requested form aggregates) and build such a dataset multi-join and/or with one sub-query per lag/lead column, with a warning naming the features. Before, the view creation failed with the database error. A dataset without those types is built exactly as before. - Live-verified on efs-dev: an XML feature refused with nothing registered; a grouped dataset and a lag dataset holding a PERIOD feature created and readable.
Added: tdfs4ds.open_session(env_file=None, database=None, token=None)¶
One call replaces the block every AI Studio notebook repeated (%gen_token, then the Vector Store token, the MCP bearer, load_config, create_context, connect). It loads the nearest .env (the current folder, then each parent folder, then ~/.tdfs4ds/.env; the file used is logged and returned; pass a path to choose another, False for none), takes a current token (the one given, else the platform's own, the way %gen_token gets it) and puts it wherever the file uses a token, opens the teradataml context from the TDFS4DS_TD_* settings, connects to the store (TDFS4DS_TD_DATABASE by default) and renews the token in the background. With a password .env it is load_config + logon + connect. connect=False stops after the logon (for setup()).
[0.3.5.16] - 2026-10-30¶
Changed: roll_out refuses dates that would add nothing to a stored history¶
A run at business date t writes its values from t onward, but never before the first value already stored. A process first run in real time (upload_features runs at once by default) or at a later date therefore could not be rolled out over past dates: every step rewrote the period from the first stored value onward, the history ended as one period holding the last date's value, and nothing said so.
- Checked before step 1.
roll_outreads where each process's stored values start (oneMIN(ValidStart)over its storage tables, filtered on its process id) and compares it with the first date it would roll out (time_id_start). When the values start later, it raisesValueErrornaming the processes, the stored start and the fixes, and runs nothing. roll_out(..., rebuild_history=True)deletes the stored values of those processes from the first date rolled out onward (ValidStart >=that date: in this case all of them; earlier periods are never touched), and their entity-refresh rows (logged inFS_REMOVAL_LOGas aVALUESremoval), then replays every date withforce_compute=True. With nothing to delete it only forces the recomputation.- Not checked: a process with no stored value yet, a process whose valid time comes from a source column (
valid_time_column), a manager object withoutdate_of. A start that cannot be read is a warning and the roll-out goes on. TimeManager.date_of(time_id)returns the business date of a step, formatted likeget_date_in_the_past(), without moving the cursor.- Live-verified on efs-dev: a process run in real time is refused for a roll-out from 2024-01-01; with
rebuild_history=Truethe three monthly values match an independent reference; a second roll-out is accepted.
[0.3.5.15] - 2026-10-30¶
Changed: roll_out keeps processes on soft clones consistent, and finds the manager itself¶
- Soft clones follow the manager.
roll_out([p1, p2], tm)wherep2's view reads a soft clone oftm(same table of dates, own cursor:clone_timer(clone_mode='soft'),split_time_manager) used to warn and runp2at its own, unmoved cursor while stamping its values withtm's dates. The clone's cursor is now moved to the same step before the processes run, at each date;p2keeps its own pairing. Verified on efs-dev: two processes with the same SQL, one ontm, one on its soft clone, store the same values at every date. - Another table of dates is refused, even under
TIME_MANAGER_CHECK='warn', before anything runs (a known-inconsistent result used to be written with a warning). A hard clone has its own table, so it is refused too: roll it out with its own manager. A view that reads no manager is unchanged (warning under 'warn'). roll_out(process_list)without a manager uses the TimeManager attached to the processes (process catalog). They must share one table of dates (a manager and its soft clones; the owner of the table drives); otherwiseValueErrorlists the managers.roll_out(process_list, tm)is unchanged.tdfs4ds.utils.time_management.point_time_manager_view(the statementupdate()runs, for a view whose table lives in another database). The clone log lines ofclone_timer/clone_filterare ASCII (an arrow made logging fail on a Windows console).
Changed: skills carry the traps of a time-managed ML use case¶
fs-rollout, fs-dataset, fs-upload, fs-document, fs-tdstone2 and td-analytics-workflow gained what the propensity demo (notebook dev/propensity) ran into: month-start calendars with monthly lags (ADD_MONTHS clamps month ends), registering with run_now=False before a backfill, the train (history + lead) / score (snapshot following the TimeManager) / monitor recipe, what FULL OUTER and INNER do with lags, CAST to FLOAT before a division, a BYOM / ONNX scoring process picking the model trained at or before the business date, KPIs as a process, documenting sources first, and checking that the Script operator is licensed before choosing tdstone2.
Added: documentation knows the in-database operators a process calls¶
A process view that scores a BYOM model or compresses time series with td-ts-compressor was opaque to the LLM: it saw a function call and string parameters. tdfs4ds.genai.operator_context recognises known operators in the SQL and gives the documentation (upload_features(document=True), intermediate views) and the agent's EXPLAIN a fixed block written by code, as ground truth. A SQL calling no known operator gets nothing: every other prompt is unchanged.
- BYOM (
ONNXPredict,PMMLPredict,H2OPredict,DataRobotPredict,DataikuPredict,MLeapPredict,ONNXEmbeddings,ONNXSeq2Seq,ONNXClassification, any database qualifier:TD_MLDB,mldb...): the model source is followed to its table (directly, the common case, or through views, at most 3 levels; oneDBC.TablesVread decides), and the table's metadata columns are read (no LOB / byte / JSON column; 20 rows at most, the rows a simpleWHEREselects first, then newest first). The block gives the input, the model source and the selection rule as written, the model rows (the selected one marked), the USING parameters and the meaning of the output columns. A refused read becomes a note, never a guess. Live-verified on efs-dev: the documentation and EXPLAIN of a scoring process name the model in use and the one it replaced, from the table's own metadata. - td-ts-compressor (
ts_compress_totable operator; mappingts_compress_seq_to_map, any qualifier, or a renamed mapping recognised by its USING signature; the unrelated system functionTD_SYSFNLIB.ts_compressis not matched): per call the series, time and value columns, the parameters as written with their defaults, the mode they imply (visual fidelity, analytical points, fixed budget), whatcompression_importance/compression_distancemean, and the featuresfeature_sqlderives (compression_ratio,distance_rank_N...). Live-verified on efs-dev on a process built from td-ts-compressor's own SQL, with and without its business-dictionary rows (imported rows are kept as written).
[0.3.5.14] - 2026-10-29¶
Fixed: consumer-agent answers found weak on the propensity demo (seen live on AI Studio)¶
- "Which datasets are used to train the propensity models?" listed every dataset of the store. A dataset inventory now keeps its topic words and its purpose: the topic narrows the list to the datasets whose name, entities, data domain or description share a word with it, and "train" / "entraîner" keeps the datasets that carry a target (a LEAD offset column, else "train" in the name). Each filter narrows only when something matches; "what are the available datasets?" and "datasets for CustomerID" give exactly the answers they gave before.
skill_list_datasets(topic=, purpose=), both optional. - A search answer whose documentation was trimmed for length said documented training datasets "lack documented details": the synthesis is now told that part of the documentation was left out, and never to say something lacks documentation. The trace fields
dropped_unverified/dropped_for_budgetare no longer given to the language model (it repeated them as "flagged ... may require verification"). - Features of two data domains were presented as one use case: each feature match now carries its data domain (a dataset's lag / lead column takes its base feature's), and matches from several domains are grouped by domain.
- Lag and lead columns of datasets (
N_EVENTS_LAG_1MO) are known names for the agent's grounding checks: they were dropped from search answers as unverified. - Documented data read by the store's processes (a model table read by the scoring process) is named as their source data, with the processes that read it, instead of "outside the feature store, documented data also covers this". The line for data no process reads is unchanged.
Fixed: two package gaps found running the propensity demo¶
upload_features(run_now=False, dataset_view_name=...)dropped the dataset. The name only reachedrun()as an argument, so the laterroll_outbuilt no dataset and nothing said so. It is now recorded as process settings (DATASET_VIEW_NAME,DATASET_GROUPEDinFS_PROCESS_SETTINGS, no DDL), and arun()that passes nodataset_view_namebuilds the recorded one (soroll_out, at each date). An explicitdataset_view_namestill wins; a failed record is a warning namingrun(pid, dataset_view_name=...).run_now=Truerecords nothing: unchanged. A run reads the process settings once more (fail-open).TimeManager.load_time_stepsfailed with Error 3515 ("Duplication of column BUSINESS_DATE") when the source column was namedbusiness_date(any case), or when the source had aTIME_IDcolumn: the two columns it adds collided. Such a source now keeps only those two columns from the start (assign(drop_columns=True)); any other source gets the same statement as before.
Added¶
- notebook dev/propensity/: a conversion propensity use case end to end. Simulated web interactions of 8,000 prospects; a TimeManager on month starts; two feature processes rolled out over 9 months; training on historical datasets with lags and a one-month lead as target; two models trained at two dates, exported to ONNX and stored with BYOM; each month scored by the latest model trained by then (a process); validation as a monitoring process (ROC-AUC and top-decile lift of last month's scores against this month's conversions, with a history); the agent. Live on AI Studio: out-of-time ROC-AUC 0.67 to 0.75, lift 2.3 to 3.3.
[0.3.5.13] - 2026-10-29¶
Fixed: two answers that depended on how the model filled its fields (seen live on AI Studio)¶
- "Top 5 customers by TOTAL_SPEND" answered "No feature named 'TOTAL_SPEND' for entity 'customers'" when the classifier wrote the entity as the plain word: with one feature of that name, its own entity is now used.
- "Which dataset gives me the RFM features?" answered "DS_CUSTOMER_RFM is not the name of a registered feature" when the classifier returned the dataset's own name: a registered dataset named exactly (any case, with or without its database) is now answered as that dataset.
Changed¶
- notebook dev/agentops/notebook_easy/: each notebook opens with a diagram of what it does on the data plane (the values) and the metadata plane (what they mean), drawn as SVG (the Mermaid version clipped its labels in JupyterLab). Notebook 04 asks the agent on both planes: catalog and documentation, the top values from the stored features, and a sample through the MCP server.
[0.3.5.12] - 2026-10-29¶
Changed: deploy_to_agentops prepares two things notebooks did by hand¶
- The pod's database address (
pod_host='auto', new last parameter): an AgentOps pod cannot resolve the database name a notebook uses. On AI Studio the name is now resolved where the deploy runs (as is, then withcop1..cop3, as the driver does) and the pod'sruntime_configgets the address; the session'sTDFS4DS_TD_HOSTis not changed. Off AI Studio, or when the host is already an address, nothing changes. A string gives the host explicitly;Nonekeeps the session's value. The resolved value is inreport["pod_host"]. - The platform's
agent_runtime: withagent_runtime='auto', a package folder without one now gets the copy the image ships (AI Studio:~/Demo/AgentOps/agent_runtime) before tdfs4ds generates its own. A copy tdfs4ds generated on an earlier run used to be reported as the platform's and kept at its old version; it is now replaced (by the platform's copy when there is one, else regenerated). A real platform copy already in the folder is still never touched.
Changed: the app starts from the generated .env and the session token¶
generate_ai_studio_envwritesTDFS4DS_SCHEMA(the user's database, wheresetup()creates the store). Without it the app opened on no feature store, which is why the demo patched it into.envbefore launching. It is not applied while the file is checked, since the store may not exist yet.launch_app(token=user_token)(new last parameter): the app logs on with the session token and gives it to the Vector Store and the MCP gateway, over the token the.envholds (which expires about two hours after it was written). Nothing is written to the file. A token cannot go into the configuration once and for all: it expires, so it is passed at launch.- On AI Studio the app renews its own token (new
tdfs4ds.utils.session_token).%gen_tokencalls the platform moduleoauth_token_helper, which asks the JupyterHub hub for the user's current token (the hub renews it every 20 minutes); any process of the user's server can call it. When the.envlogs on with a token,launch_appstarts the app with the current one (notoken=needed) and the app renews it every 15 minutes: the stored logon used by an automatic reconnect, the Vector Store token and the MCP bearer, each only where a token was already in use. Live-verified: an app started from a.envwhose token had expired 250 minutes earlier connected and started renewing. Inert elsewhere: withoutoauth_token_helper, or with a password logon, nothing changes. tdfs4ds reads no unprefixed variable; the platform's module does. generate_ai_studio_envwrites theupload_featuresdefaults that match its models:UPLOAD_DOCUMENTandUPLOAD_EXPLAINon when an instruct model is configured, off otherwise;UPLOAD_VECTOR_INDEXINGon only when the embedding model is configured too (the index is built from the documentation);UPLOAD_OPTIMIZE_QUERYalways off (it rewrites the user's view, which must be asked for). The package defaults are unchanged; an explicit argument still wins.
Added¶
- New notebook dev/agentops/notebook_easy/: the AgentOps demo in eight short notebooks.
[0.3.5.11] - 2026-10-29¶
Changed: the AgentOps registry entry names the feature store¶
- Before: every agent deployed by
deploy_to_agentopswas registered as "tdfs4ds feature-store consumer agent" under the nametdfs4ds-consumer-agent-<uuid>, so with several agents deployed nothing told which store each one serves. - Now: the default description states the feature store, its view database, its data domains (read from the process catalog; at most 8 listed), the stores attached read-only, what the agent answers, MCP on or off, the logon mode and the tdfs4ds version. It carries no counts, since those go stale as the store grows. The default name is
tdfs4ds-agent-<database>-<8 hex>, and the deployment now uses the same name as the definition (each used to get its own random one). A dry run shows both inreport["definition"];name=anddescription=still override them.
[0.3.5.10] - 2026-10-29¶
Fixed: no traces in the AgentOps Observability panel¶
- Cause: AgentOps names each agent's Opik project after the agent (
OPIK_PROJECT_NAME= its agent id, set in the deployment's runtime config), and the Observability panel shows that project only. tdfs4ds passed its ownproject_name='tdfs4ds'to the Opik tracer, and the deployment package wroteTDFS4DS_OPIK_PROJECT_NAME: tdfs4dsintoconfig.yaml, so every trace of a deployed agent went to a project calledtdfs4ds(seen live: traces accepted by Opik, nothing in the panel). - Now: the tracer passes a project only when
tdfs4ds.OPIK_PROJECT_NAMEis set;Noneleaves it to the Opik SDK's own configuration. The deployment package writesNonewhen the session has the default (tdfs4ds) or nothing, so the pod traces into the platform's project; a name set on purpose is kept. Locally nothing changes: the default is stilltdfs4ds. - An agent deployed with an earlier version keeps tracing into
tdfs4ds: redeploy it (notebook06) to see its traces in the panel.
[0.3.5.9] - 2026-10-29¶
Found by a multi-turn test of the agent deployed on AgentOps (notebook 08).
Fixed: an empty answer to "Is it up to date?" in a new conversation¶
- Cause: with tool calling (
STRUCTURED_OUTPUT_METHOD='function_calling'), a model that makes no tool call leaves the classifier with nothing: the parser returnsNoneinstead of raising, and the turn failed on it. The deployed agent answered with an empty text. - Now: the classification is asked once more, then treated like any unusable one. A question that only refers back ("it", "this") with nothing in the conversation goes to RECAP, which says there is nothing to refer to yet. Other questions keep the SEARCH fallback.
Fixed: "the second one" after a listing¶
- "What does the second one mean?" after a listing of features asked which item was meant. An ordinal that stands for an item ("the first one", "the last feature", "le deuxième") is now replaced by that item of the previous answer: its last numbered list, else the names of its table rows. "the last month" and an ordinal beyond the list are left alone.
Changed: MCP tools that take the database apart, decided by the server's answer¶
- 0.3.5.7 split a qualified
table_nameintodb_name+table_namebefore every call to a tool with adb_nameargument. Now the call is sent as the model wrote it, and only a 3807 naming that qualified name ("Object 'db.table' does not exist", the Enterprise server) is retried once with the database apart, in the tool's own argument (db_nameordatabase_name). The Community server, which buildsdb.tableitself, gets exactly the calls it got before 0.3.5.7. Live: the Enterprise retry returned the sample.
Fixed: the deployed-agent check reported a session leak¶
check_deployed_agent's isolation test passed only on a few wordings of "I don't know what you mean". The agent's "I don't have any features or datasets in our conversation context yet" was reported as a leak. A fresh session that names nothing of what the first session resolved now passes; naming it still fails. Checked live with two interleaved conversations.
Added: notebook 08 - use the deployed agent¶
- In
notebook dev/agentops: calls<agent_url>/invokewith the token (single questions, a conversation throughsession_id, streaming), runscheck_deployed_agentand reads the pod log.
[0.3.5.8] - 2026-10-29¶
The consumer agent, from the AI Studio demo. Each change applies only where the agent failed before: a name that resolves today resolves the same way, and no LLM call is added.
Fixed: a concept the catalog knows by a dataset¶
- Before: "Which dataset gives me the RFM features?" answered "No dataset exposes RFM features" (DATASET) or "Nothing in the feature store matches" (SEARCH), although the dataset
ds_customer_rfmis registered. The agent's name recognition only matched exact catalog names, and the DATASET answer read every name as a feature. - Now: when a name matches no catalog object exactly, the recognition (
node_recognize_entity,_recognize_object_type(..., concepts=True)) tries, in order, the one dataset whose name holds its words ("RFM features" →ds_customer_rfm), the one dataset whose documentation does ("recency features"), then the features whose own description matches it. The recognition is now kept in the turn's state (recognized_entities). DATASET answers with the dataset and its features; SEARCH does the same when it found nothing, using the question's acronyms when no object was named. A unique match only. Live on AI Studio: both questions answered withds_customer_rfm. - A name that is no feature: "No dataset exposes X" is now said only of a real feature. A name that is no feature reads "X is not the name of a registered feature", followed by the registered datasets.
Fixed: "give me a sample" after a question on a dataset¶
- Before: after a domain listing then a freshness question on
ds_customer_rfm, "give me a sample" listed CUSTOMERID values: the entity left in focus by the listing won. - Now: a sample request that names nothing ("give me a sample", "show me some rows", "un échantillon") while the conversation is about a registered dataset reads rows of that dataset (DATA_QUERY with its qualified name). Without MCP it says that reading rows needs MCP. Live: the sample came from
dm250067.ds_customer_rfm.
Fixed: numbered replies¶
- Before: a reply "2", or "1 last month, 2 customers", to a numbered list in the previous answer (next steps, questions) was classified on its own and lost its meaning. Only the agent's own clarification prompts were connected.
- Now: when no flow is waiting for a reply, such a reply is written out with the items it points at ("Which time window: last month; Which entity: customers") before classification, and the skill answers that. Every number must be an item of the last numbered list of the previous answer, and the reply must start with one; anything else is classified as before. Verified offline only.
[0.3.5.7] - 2026-10-29¶
More from the AgentOps demo on Teradata AI Studio. As in 0.3.5.6, each fix applies only in the situation it fixes.
Fixed: a dataset sample through the Enterprise MCP server¶
- Before: "give me a sample of the ds_customer_rfm dataset" answered that the dataset does not exist. The agent names datasets with their database, and the Enterprise server's
base_tablePreview(alsobase_tableDDL,base_columnDescription...) takes the database indb_name: giventable_name='db.dataset'it looked for an object of that name (Error 3807). The Community server, used so far, answered the same call. - Now: when a tool has a
db_nameargument the model left empty, a qualifiedtable_name/obj_nameis split into the two arguments. Other calls are sent unchanged.
Fixed: dataset sections still left out¶
- A section still missing after the follow-up call of 0.3.5.6 is asked alone, as plain text. Asked alone through tool calling,
FEATURE_THEMEScame back as an empty object every time (Ministral on Bedrock, behind LiteLLM). Live: 5 sections of 5 written. - Several tool calls in one reply: with tool calling, a model may spread its answer over several calls of the same tool (seen: one call per theme). Their arguments are now merged; only the first was read. A reply with one call is read as before.
Fixed: the app on AI Studio¶
launch_appon a JupyterHub server: its proxy check reported "install jupyter-server-proxy" while the proxy worked. A JupyterHub single-user server refuses the token of its own runtime file, and the proxy answers an unauthenticated request with a 404. When the server's API refuses that token too,proxy_okis nowNone(not checked) with a note to open the link. Elsewhere the check is unchanged.- Verified on AI Studio: through
jupyter-server-proxy(installed withpip install --user, see the docs) the app at<jupyter base>/proxy/absolute/8000/reports connected with the token, the Report and Lineage panels load, and the chat streams (SSE) when the request sendsAccept: text/event-stream, as a browser does.
Changed: smaller items¶
check_configuration, LLM transport: a LiteLLM gateway was reported asINTERCEPTED: GET <base> -> 200 text/html. LiteLLM answers a bodiless GET on/chat/completionswith a 500, so the probe went on to the base URL, which serves LiteLLM's web console. Any error status from/chat/completionsnow counts as the API answering; a redirect or a 200 page there is still reported.markdownis now a dependency (install_requires). The chatbot, the query-optimizer report and the admin panels render Markdown with it; it was declared in theserveextra only, so a plain install showed raw Markdown there.
[0.3.5.6] - 2026-10-29¶
Found by running the AgentOps demo on Teradata AI Studio. Each fix applies only in the situation it fixes; everywhere else the behaviour, the SQL and the requests are unchanged.
Fixed: the app logs on with a token¶
- Before: with a token logon (
TDFS4DS_TD_LOGDATA='token=…',TDFS4DS_TD_LOGMECH=JWT, no password), as on AI Studio, the app started disconnected ("missing connection field(s): password"). - Now: when no password is set and
TDFS4DS_TD_LOGDATAis, the app opens its connection with it, the way the automatic reconnect already did. A password, when set, is used as before. launch_appno longer says the app starts disconnected when the only missing setting isTDFS4DS_TD_ENCRYPT: the app connects without wire encryption.
Fixed: agent questions answered "nothing matches" behind tool calling¶
- When:
STRUCTURED_OUTPUT_METHOD='function_calling'(for example LiteLLM in front of Bedrock). - Cause: a few agent calls ask for a JSON list, and a tool's parameters must be an object. Bedrock refused the request, and SEARCH answered that nothing matched.
- Now: a list is sent as the single property of an object and taken back out of the reply. Requests asking for an object are unchanged.
Fixed: grouped datasets documented with only some of their features¶
- The dataset documentation found its features with a pattern that fits datasets built without
grouped=Trueonly. A grouped dataset of 6 features was documented as having 2. - Now every feature is found, whatever the dataset's shape. Datasets built without
grouped=Trueget the same documentation as before. - Sections left out by the model: a reply that held only some of the five sections (seen with tool calling on Bedrock, which does not enforce required keys) is completed by one more call asking for the missing ones. A complete reply makes no extra call. A section returned as a list or an object is stored as text, one item per line.
Fixed: an expired token reported as a trust problem¶
- The AgentOps preflight and
generate_ai_studio_envtest the token inTDFS4DS_TD_LOGDATA. An expired token was refused by the database and reported as "Vantage has to be configured to trust this token's issuer". - Now the token's expiry is read first: an expired token is reported as expired ("mint a fresh one with %gen_token"), and no logon is attempted.
Fixed: the agent and names written in plain words¶
- A dataset named in words: "the customer RFM dataset" now resolves to
ds_customer_rfmwhen no closer name exists (only a single match counts). - A follow-up naming its own object: a question that names something other than the object of the previous turn is no longer answered about that previous object.
- A feature named by its meaning: "how is the recency feature calculated?" now lists the documented features whose description contains the word (here
DAYS_SINCE_LAST_TXN, "most recent transaction") instead of only saying it was not found. The vector index only orders them: on its own it ranked spend features first. The agent does not switch to one by itself.
Changed: smaller items¶
- Statistics: the first statistics collection on a new feature table no longer logs a warning (Error 3624 "no statistics defined" is expected there). Other failures show the whole message.
- Documentation:
upload_features(document=True)no longer has the LLM document the same process a second time while documenting its dataset. - Data domain:
select_data_domainlogs a warning, not an error, before raising for a domain that does not exist yet.
[0.3.5.5] - 2026-10-28¶
Added: attached objects in the admin app¶
- New panel, Documentation › Attached objects:
- the list of attached views and tables, with Sample and Detach on each row;
- Check for changes and Apply the changes (
refresh_attached_objects); - Attach, a preview first, then the write. Each object is attached on its own, so one that cannot be attached shows its reason without stopping the others;
- Suggestions: the views of the given databases that read a dataset or a process view.
- Documentation › Objects: the detail of a view or source table has Attach to the feature store… or Detach from the feature store.
- Explore › Dataset builder: next to an entity's datasets, the views attached to them are listed, each with a sample.
Added: a sample is read only after its EXPLAIN is checked¶
- Why:
SAMPLEapplies to a view's result, so the whole view is computed. - What is checked: the panel reads the EXPLAIN of
SELECT * FROM <view> SAMPLE 20first, with the new pure parsertdfs4ds.utils.explain_cost. It reads the per-step estimated times, the intermediate result sizes, the***overflow, the product joins and the confidence. - Above the limits: an estimate above 60 s (
tdfs4ds.SAMPLE_MAX_SECONDS) or above 10 GB of intermediate result (tdfs4ds.SAMPLE_MAX_SPOOL_GB). - A confident estimate is refused: the plan is shown, and Sample anyway runs it.
- A low- or no-confidence estimate (statistics missing) only warns, and the sample runs.
- Cancellable runs: a sample above the limits runs on its own database connection, with its elapsed time and a Cancel button. Cancel stops the query in the database (
connection.cancel(), Error 3110; verified live: the session was idle on the AMPs right after). A sample still running after 10 minutes is cancelled (tdfs4ds.SAMPLE_JOB_MAX_SECONDS). Without theTDFS4DS_TD_*settings, a sample above the limits is refused, and Sample anyway reads it on the app's connection, which cannot be cancelled. - Estimates can be far off without statistics: on efs-dev, a dataset view of 9 categories was estimated at millions of hours and read in 2.3 s.
- What can be sampled: only an attached object or a suggested view.
Changed: column names of several versions side by side¶
- A feature read under several process versions now gives columns named after the bare process view:
amount_FEAT_V1. They used to beamount__DB___FEAT_V1_, because the catalog stores the view name qualified and quoted. - This matches the names the admin app's dataset builder already gave.
- Existing dataset views keep their columns until they are rebuilt with
build_dataset.rebuild_datasetand migration keep the names recorded in the view.
[0.3.5.4] - 2026-10-28¶
Changed: datasets from feature names alone¶
build_datasetandbuild_historical_datasetaccept the feature names alone:selected_features=['amount', 'nb_tx']. A feature computed by one process is resolved to that version.- When to give a version: only for a feature that several processes compute. Write it in a dict with
Nonefor the others:{'amount': '<process uuid>', 'nb_tx': None}.{'amount': {'alias': 'spend'}}names the only version. - Errors: such a feature named without a version raises
tdfs4ds.FeatureSelectionError(aValueError). The message lists the processes with their ids and gives the dict to paste, for one version or for all of them side by side. A name not registered for the entity in the current data domain, or a feature no process computes, raises the same error. All the problems are reported at once. - A full dict (
{feature: process uuid}, the earlier form) gives the same view as before. - A version that no current process has is now logged as a warning. The values of a removed process may still be stored, so the build goes on.
- One catalog query: the selection is resolved with one query whatever the number of features. A build used to read the catalog two or three times.
- Verified live on efs-dev, on a single-version feature set, a feature with three versions, a history and lags.
[0.3.5.3] - 2026-10-28¶
Fixed: valid_time_column with a PERIOD feature¶
A process with valid_time_column and a PERIOD feature (PERIOD(TIMESTAMP(0) [WITH TIME ZONE])) failed at every
run with Error 6976 "Period data types are not allowed with Window function": the history write computes each
period with window functions, and Teradata refuses a PERIOD value in such a query. The staged column types are now
read once (TYPE() on one staged row), and a PERIOD feature goes through the same window queries on its
BEGIN / END bounds, the value being rebuilt in the outermost SELECT. Same periods and the same change rule
(equal consecutive values merged, NULL-safe). Other types issue exactly the statements they did.
[0.3.5.2] - 2026-10-28¶
Views and tables attached to the feature store¶
A registered dataset joined with a source table in a user view that no process reads, or a table brought in by hand,
was "outside the feature store" for the agent, and a full rebuild_lineage_graph() closed the edges an extra_views=
call had recorded. attach_objects declares such objects part of the feature store.
- API (exported on
tdfs4ds):attach_objects(objects, data_domain=None, *, document=True, refresh_lineage=True, refresh_index=True, author=None, dry_run=False),detach_objects(objects, dry_run=False),attached_objects(data_domain=None)(DATABASE_NAME, OBJECT_NAME, KIND, DATA_DOMAIN, DOCUMENTED_AT),refresh_attached_objects(objects=None, *, dry_run=True, document=True, refresh_index=True, author=None)andsuggest_attachments(databases, *, include_processes=True)(VIEW, READS, ATTACHED; read only, never attaches). A process view, a dataset, anFS_*/*_HIDDENobject or a name not found in the database is refused. - Marker. The reserved business-dictionary section
ATTACHED(JSON: kind, optional data domain, fingerprint, documentation time). No new table; every section reader skips it (RESERVED_SECTION_NAMES). The reads anddetach_objectslive intdfs4ds/attached_registry.py, which imports nothing fromgenai, so lineage, dependencies and the workflow assessment can use it. - Lineage. The edges of an attached view are recorded at attach, and every full
rebuild_lineage_graph()seeds attached views. An attached table that no view reads has no edge until an attached view reads it. - Documentation. The pass-through columns of a view (a plain column of one
FROM/JOINrelation, renamed or not,t.*too) take the description of the source column (PROPAGATED). Computed columns and the object description are written by the LLM (skipped without an instruct model). A table is documented by its owner (set_object_documentation, dictionary import).IMPORTEDandMANUALparts are never overwritten. - Refresh, also run by
rebuild_documentation. The fingerprint is the normalised view text plus one hash per column (select item and source), or the column types of a table.dry_run=Trueby default. States: unchanged: skipped, no write, no LLM.sources_changed: a source's documentation changed after the last pass; pass-through columns are copied again, no LLM.changed: the view text or columns changed; added and changed columns are documented, the rows of removed columns are ended, the LLM runs only when a computed column or the sources changed, lineage is recorded again.broken: the object exists and itsEXPLAINfails; reported, nothing written.disappeared: the object is gone; marker ended, lineage closed, documentation ended (readable at a past date), a person's parts kept and reported, a row written to the removal log.- Agent. Attached views and tables are inside the feature store (kinds "attached view" / "attached table"). A fixed
EN/FR sentence says "Attached to the feature store: X is an attached view ... It reads
DS(dataset),T". A dataset or process view read by attached views is described as "used by the attached views ...". Search answers show attached objects in their own block before the outside block. - Guards. Removing a dataset or a process read by an attached view is refused (
DependentObjectsErrornames it). The removal plan has adetach_objectstep; the view itself is never dropped. The network check of the workflow assessment counts attached views as consumers: they are not reported as detached, and the processes they read are not reported as unused. - Guarded by
tests/test_attached_objects.py. Live-verified on efs-dev: a view joining a registered dataset with a documented table attached with the table; pass-through columns documented from their sources (PROPAGATED), lineage edges recorded, the agent's sentences in both directions, the removal of the dataset refused with a detach step, refresh states unchanged / sources_changed / changed / broken / disappeared, suggestions, detach. Not exercised live: the LLM part (no model configured) and a full rebuild_lineage_graph().
[0.3.5.1] - 2026-10-28¶
Types of features¶
The text length of a feature was fixed by the first run that registered it, a re-registration with another type was ignored silently (values cast or truncated), and a stored type could not be changed. This release makes the length a process setting, compares types by whether the values fit, and adds the migration of a stored type with its history.
force_varchar_lengthis a process setting. Recorded inFS_PROCESS_SETTINGS(FORCE_VARCHAR_LENGTH) at registration.upload_features(force_varchar_length=None)(the new default) keeps the value recorded for the view's process, else 1024, so a re-registration never resets whatset_process_optionsrecorded;0or'none'records exact lengths.run()/roll_out()takeforce_varchar_length=None, meaning "as recorded" (1024 when nothing is recorded, i.e. a process registered before 0.3.5.1; before,run()applied no rounding, so a feature first registered by a run got an exact-length table).set_process_options(pid, force_varchar_length=N)or'none'changes it.get_process_info(pid)['FORCE_VARCHAR_LENGTH']shows it. Migration carries it over.- One VARCHAR type per process and character set. A new text feature takes the stored type of a registered text
feature of the process that holds it (it joins the process's text table); otherwise the new text features share
VARCHAR(ceil(longest / N) * N), the longest taken over the new features only. LATIN and UNICODE stay apart, so a process has at most two text tables, and never more tables than before (with 1024, texts up to 1024 land in the usualVARCHAR(1024)table). Registered features never change type here, and each one is checked against its own source type (a short column is never reported because another one grew). - No empty storage tables. Registered features keep their catalog type when the storage tables are ensured. A run used to create an empty exact-length table (seen live).
- A changed type of an existing feature is compared by whether the values fit, not by equality. Fitting: a shorter VARCHAR of the same character set or into UNICODE, an integer into a wider integer, FLOAT or a large enough DECIMAL, a smaller DECIMAL, a lower TIMESTAMP precision. A fitting type changes nothing.
- A type that does not fit is refused at registration.
upload_featuresregisters nothing and returnsNone. The log explains three alternatives, with code: (1)CASTthe column in this view to the stored type (the select item is quoted when found); (2) widen the stored type for every process computing the feature:tdfs4ds.plan_type_change(types={...}, entity=[...], data_domain=...), thenwrite_relocation_script, dry run,--apply(the other processes whose type would not fit the new one get theirCASTsnippet; the others are listed as "no change needed"); (3) incompatible types (text vs number, ...): give the column another feature name. A migration only widens to the narrowest type holding every producer's type, so processes do not undo each other.run()/roll_out()log the same text as a WARNING and write with the stored type. - Truncation warning. When a registered VARCHAR feature's source is longer than its stored type, each batch reads
the longest staged value (one aggregate) and warns: it fits, or N values are too long (truncated in Teradata mode,
refused in ANSI mode). The batch writes and stays
COMPLETED. - Migration of a type.
plan_relocation(..., value_types={feature: type})(no tag needed; an existing tag is kept), orplan_type_change(process_id)/plan_type_change(types=, entity=, data_domain=). A newFS_T_table with the newFEATURE_VALUEtype (same primary index and partitioning), the full history copied and verified, catalogFEATURE_TABLE/FEATURE_VIEW/FEATURE_TYPEupdated, datasets rewritten and checked, statistics, old rows deleted last.value_conversionblocks when the stored values do not convert (a text narrowing is allowed when the longest stored value fits).verify_relocationchecks the catalog type. valid_time_columnmay still be changed freely: no table definition is involved.- Compatibility. A process registered before 0.3.5.1 keeps 1024. Behaviour change: a re-registration with a type that no longer fits is refused instead of silently cast or truncated.
- Guarded by
tests/test_type_changes.py. Live-verified on efs-dev: one text table per process and character set (LATIN and UNICODE apart, a non-Latin value round-trips), the table name unchanged from earlier versions, no empty table afterrun(), a grown column refused alone with the alternatives, therun()and truncation warnings (a 1500-character value cut to 1024), a VARCHAR migration with its history and a dataset, INTEGER to FLOAT, an incompatible change, a shared feature without ping-pong, the narrowing block, a recorded setting kept by a re-registration, exact lengths with0.
[0.3.5.0] - 2026-10-28¶
Valid time read from a column of the source (valid_time_column)¶
A source that already holds a history (one row per entity and valid time) needed one run per date: a TimeManager
loaded from the distinct dates, then roll_out. A process can now read the valid time from the data.
- Option.
upload_features(..., valid_time_column='EVENT_TS')(last parameter, defaultNone). Recorded as the process settingVALID_TIME_COLUMNinFS_PROCESS_SETTINGS(no DDL). Change it withset_process_options(pid, valid_time_column='COL'), switch it off withvalid_time_column=False. Read it withget_process_info(pid)['VALID_TIME_COLUMN']. Migration carries it over. - Meaning. The column is a DATE or TIMESTAMP of the source (neither an entity column nor a feature). A row's
value is valid from that time until the entity's next row (last row:
9999-01-01). One run writes a whole history, and the source may hold several rows per entity. The unique key is (entity, column): a NULL valid time or a duplicate (entity, time) refuses the batch, and the error names the column. - Rules. Equal consecutive values are merged (NULL-safe). Each entity's history is replaced from its earliest valid time in the load (t0) onward and kept before it. A re-run is a no-op, an incremental load rewrites only from its own t0, and a backdated correction works with a full load. An incremental load can leave two adjacent periods with the same value at its start; a later full load merges them, and history datasets NORMALIZE them.
- Valid time of the run.
FEATURE_STORE_TIMEand the TimeManager date no longer set the values' valid time (a TimeManager can still bound the rows the view returns). The follow-up row is unchanged (noFS_FOLLOW_UPcolumn). - With the other options.
close_absent_entities: an entity absent from a load has no value from the load's earliest valid time on.track_entity_refreshrecords the load's latest valid time. The process SQL version is captured at the load's earliest valid time. - Types. DATE becomes midnight. TIMESTAMP(6) is truncated to the second, through its text (a CAST from TIMESTAMP(6) to TIMESTAMP(0) is Error 7454). Without a time zone, the session time zone applies.
- Refusals. Not for a UAF ART view. Registration returns
Noneand registers nothing for an entity column, a feature, a missing column, a non-date type, or a store withoutFS_PROCESS_SETTINGS. The read is fail-closed: if the settings table exists but cannot be read,run()raises (a history load stamped with the run time would be wrong). - Write path. Per storage table, one multi-statement request (one transaction): a NONSEQUENCED DELETE of the rows
starting at or after each entity's t0 (joined to the volatile table
TDFS_VT_KEYS), a NONSEQUENCED UPDATE trimming the row that spans t0, and one NONSEQUENCED INSERT per feature of the computed periods. Code infeature_store/valid_time_column.py. - Compatibility. Opt-in. Without it every statement is unchanged. A client older than 0.3.5.0 running such a
process ignores the setting: several rows per entity are refused at staging as duplicates, one row per entity is
stamped with the run time. The earlier route (a TimeManager from the distinct dates and
roll_out) is still valid. - Guarded by
tests/test_valid_time_column.py(55 offline tests). Verified on Vantage 20 (efs-dev): first load equal to an independent reference, idempotent re-run, incremental load, backdated correction,close_absent_entities,track_entity_refresh,build_dataset(as_of=)andbuild_historical_dataset, the refusals, DATE and TIMESTAMP(6), FilterManager segments, and a control process without the option.
[0.3.4.7] - 2026-10-27¶
Lineage stops behind exposing views are recognised; one grouped warning¶
Field log on 0.3.4.6: a process reading ten tables through views that expose them still warned about all ten, each with the full Teradata message and its own GRANT advice.
- Exposing views recognised in the lineage. The lineage graph is built without view texts, so 0.3.4.6 had nothing
to check and recognised no exposing view.
record_process_lineagenow reads, with SHOW VIEW, the views right above the objects it could not read (only when there are such objects, each view once). Tables read through a view that exposes them give one info line and no warning. - One warning for the rest. Objects refused with the same message are listed once with the message once, and the GRANT advice is grouped per database, with a note that no grant is needed when they are read through views that expose them.
- Guarded by
tests/test_exposed_views.py(a graph shaped like the builder's, without view texts, and ten stops).
The agent compares documented columns of tables and views¶
Field report: "is the MCE_NUMBER of HCF_ACCESS.HCF_FIBER_VW similar to the MACHINE_NR of the Historian?" answered
"No documentation found" for both columns, although MCE_NUMBER alone had been answered the turn before.
- Several named items were looked up in the feature catalog only. Names that are not features are now looked up as documented columns (one read for all), before the "did you mean" correction so a column is never turned into a similarly named feature. The owner named in the question wins (a qualified name, or a word of it: "the Historian"), then the table remembered from an earlier turn, else every owner; owners repeating the same text (a view exposing its table) are shown once. Each column gets the "Outside the feature store" line, as a single column does.
- Join evidence. When a process reading both tables joins the two columns (
f.MCE_NUMBER = h.MACHINE_NR), the answer gives it as the deciding fact; without it, the documented descriptions are compared and anything beyond is labelled an interpretation. - Routing. The classifier sent such a question to LINEAGE with one name (seen live) or to DEFINITION. A comparison shape (EN/FR) naming two or more documented items is now routed by code to DEFINITION with all the names, never split into a compound question.
- The trace shows a documented column as "documented column of
DB.TABLE" instead of "not found". - Guarded by
tests/test_definition_multi_columns.py. Verified on Vantage 20 through the agent with a live LLM: the comparison was routed to DEFINITION, both owners were found and the process join was given as the answer.
[0.3.4.6] - 2026-10-27¶
A view that exposes a table takes the table's documentation¶
Field report: users read a historian table through a LOCKING ROW FOR ACCESS view in another database, and have no
right on the table. Its documentation (layout, tags, units) was on the table, and the LLM pass that documented the
view kept only the physical columns, so the tags never reached the processes reading the view (verified live: units
and instruments were missing from the feature descriptions).
- Exposed views. A view exposes a table when every output column is a column of that one table, unchanged or
renamed. Row restrictions do not stop it:
WHERE(a filter onUSER), joins to an entitlement table,QUALIFY,DISTINCT,TOP,SAMPLEmark it filtered. Aggregation,GROUP BY, set operations and columns from two tables do not qualify. The mapping is read from the view'sSHOW VIEWtext: no right on the table is needed. - Documentation copied, no LLM call. The table's description, sections, columns (through the renames), historian
layout and tags are written on the view as
PROPAGATED; a part a person wrote on the view is kept. The pair is the reserved business-dictionary sectionEXPOSESof the view (no new table). - Automatic in the documentation walk (process, dataset,
rebuild_documentation) for a view over a documented table with no computed column, including a view already documented by the LLM.tdfs4ds.EXPOSED_VIEW_AUTO_ATTACH = Falseturns it off. Editing the table withset_object_documentation/set_deep_table_documentationcopies the new text onto its views and lists the processes to re-document. - API:
attach_exposed_view,detach_exposed_view,exposed_views,refresh_exposed_views,suggest_exposed_views(read-only). - Lineage. A lineage stop at a table read through a view that exposes it is an info line, without the GRANT advice. The entitlement tables of such views are not reported as undocumented.
Fixes¶
- The lineage-stop warning printed the driver's stack trace (about 20 Go frames). It now keeps the message only.
- "does not have any access" gave the advice
GRANT ANY ON ...; it is nowGRANT SELECT. - "N intermediate view(s) could not be documented (LLM failure)" was logged for views that had been documented: the function returned nothing on success. It now returns True / False.
- Entity columns of an intermediate view (e.g. the drum id of a historian view) were documented by the LLM and not stored.
- Guarded by
tests/test_exposed_views.pyandtests/test_lineage_access.py. Verified on Vantage 20 with a filteredLOCKING ROW FOR ACCESSview joined to an entitlement table onUSER.
[0.3.4.5] - 2026-10-26¶
A missing right is reported as "not verified", never as a broken view or a silent end of the lineage¶
- Where the lineage stops. The lineage builder could not read the definition of a view the user has no right on,
stopped there, and stored nothing to say so (only edges are kept): everything below was missing from lineage, impact
analysis and the checks.
record_process_lineagenow logs a warning naming each such object and the GRANT that would lift it, and the builder keeps the refusal instead of the unrelated SHOW TABLE error that followed it. - Workflow assessment, lineage network. A view of the lineage graph with nothing recorded below it is probed
(SHOW VIEW):
lineage_stops_no_access(definition refused, with the GRANT),lineage_not_recorded(readable, its inputs missing from the graph: rebuild its lineage) orlineage_stops_unreadable. Each names the processes and datasets whose lineage ends there. Objects absent fromDBC.TablesVare probed too: that view only lists objects the user has a right on. - EXPLAIN refused for a right. The network check reports
view_not_verifiable(warning) instead of "does not compile"; the integrity checkprocess_reads_inaccessible_object(warning) instead of "no longer compiles";check_downstream_processesreturns them undernot_verifiableinstead ofbroken. - Removals still refuse when a dependency cannot be read.
- Guarded by
tests/test_lineage_access.py. Verified on Vantage 20: an EXPLAIN refused with Error 5315 is classified as not verified.
[0.3.4.4] - 2026-10-26¶
Dataset documentation finds the descriptions of its process features¶
Field report: documenting a dataset over two documented processes logged "feature X not found in process P documentation" for every feature, and the process views were documented again as plain intermediate views.
- Process views recognised. The documentation walk matched the lineage graph against the process catalog with the
view name as stored, quotes included (
"DB"."VIEW", the name of atdml.in_schemaDataFrame). Names are now compared unquoted, so a process view is documented as its process. - Feature descriptions filed under their features. A first
upload_featuresdocuments the process before its run registers the features, so the feature descriptions could not be filed under their FEATURE_IDs and none was written (no message); they were stored on the process view's columns in the business dictionary only.upload_featuresnow links them after the run, andtdfs4ds.link_feature_documentation(process_id)does it for any process (no LLM; existing descriptions are never changed). - Existing stores work at once. Dataset documentation falls back to the business-dictionary description of the
process view's column when a feature has no description row, so re-running
build_dataset(..., document=True)ordocument_dataset_incrementalis enough. - Guarded by
tests/test_dataset_doc_propagation.py. Verified on Vantage 20 with a quoted process view name.
[0.3.4.3] - 2026-10-26¶
A storage definition the database refuses stops upload_features before anything is registered¶
Field report: partitioning= with RANGE_N on a DECIMAL entity column (Error 3732) was only refused when the first
storage table was created, after the process was registered. The error was logged and skipped, the features were
registered anyway, and every later run failed with "Object 'FS_T_...' does not exist", even once the partitioning was
fixed.
- Checked first.
upload_featurestries the entity columns, primary index and partitioning on a throwaway volatile table before registering anything (tdfs4ds.feature_store.storage_definition). A refusal is logged with the whole database message and what to change (RANGE_N on a DECIMAL column: cast it to INTEGER in the view; a TIMESTAMP column needs TIMESTAMP bounds; only entity columns), the view created fromsql_queryis dropped, and the call returnsNone. - No table, no registration. A storage table that cannot be created now raises instead of being logged and
skipped, so its features are never registered against a table that does not exist.
run()tries the definition first too. - Missing tables are created again. Each run creates the storage tables that are missing for every feature of the process, not only for new features (one DBC read), so a store left in the state above repairs itself on the next run.
- A changed definition is no longer ignored silently. Registering a view again keeps its recorded primary index and
partitioning (its tables were created with them); passing other values now logs a warning naming
plan_relocation(...). - Entity key types keep their precision.
DECIMAL,NUMBERandTIMESTAMPkeys are created with their exact type (DECIMAL(18,0),TIMESTAMP(0) WITH TIME ZONE, ...); the bare names meantDECIMAL(5,0)(a key above 99999 overflowed, decimals were lost) andTIMESTAMP(6)without time zone. Existing tables keep their columns; table names do not depend on the types. - Guarded by
tests/test_storage_definition.py. Verified on Vantage 20: both refusals reported, nothing registered; a dropped storage table re-created by the next run (20 rows, COMPLETED); the re-registration warning.
[0.3.4.2] - 2026-10-26¶
Log lines coloured by level, on stdout, root logger left alone¶
import tdfs4ds called logging.basicConfig on the root logger. Every line went to stderr, which Jupyter shades pink
whatever the level, so an INFO looked like an ERROR; and basicConfig does nothing once anything else has configured
the root logger, so in some kernels the lines came out plain instead. The import order decided which, and the level
never did. It also switched on INFO for every other library (httpx's HTTP Request lines).
- The
tdfs4dslogger now has its own handler (tdfs4ds/log_output.py): stdout, the same line format, and the level as a colour: DEBUG grey, INFO plain, WARNING orange, ERROR and CRITICAL red. The root logger is no longer touched, so other libraries' INFO lines no longer appear. tdfs4ds.LOG_COLOR(TDFS4DS_LOG_COLOR):None/auto(default) = colours in a notebook or a terminal that shows them, never in a file or a pipe, never whenNO_COLORis set;True/Falseto force.tdfs4ds.LOG_PROPAGATE(TDFS4DS_LOG_PROPAGATE, defaultFalse): tdfs4ds lines are no longer passed to the root logger, so they never print twice. If you collect tdfs4ds logs through your own root logging (a file, a collector), set it toTrueor calltdfs4ds.configure_logging(propagate=True): tdfs4ds then prints nothing and the lines go to your handlers, as before.python -m tdfs4ds.servedoes this, so the server log keeps one format.DISPLAY_LOGSand the admin app's live log are unchanged.- Guarded by
tests/test_log_output.py.
[0.3.4.1] - 2026-10-26¶
Fix: a dataset following a TimeManager built on a timestamp column could not be read (Error 7454)¶
build_dataset(time_manager=tm) and the dataset_time_manager option of upload_features / run() filter every
feature read with the manager's business date cast to TIMESTAMP(0) WITH TIME ZONE. A TimeManager loaded from a
timestamp column stores TIMESTAMP(6) WITH TIME ZONE, and Teradata refuses CAST(TIMESTAMP(6) AS TIMESTAMP(0)) even
with zero fractions (Error 7454, DateTime field overflow): the view was created, every read failed, the dataset was
never registered.
- The manager scalar is now cast up:
CAST(BUSINESS_DATE AS TIMESTAMP(6) WITH TIME ZONE)(constanttdfs4ds.dataset.temporal.TM_SCALAR_TYPE), in the period predicate and in the lag/lead instant. ADATEmanager casts up as well, and theTIMESTAMP(0)valid-period columns compare against aTIMESTAMP(6)value unchanged. Current and AS OF datasets are byte-identical. Views written with the old text still parse (the parser accepts any precision); recreate them (rebuild_dataset(..., replace=True)) to make them readable. build_dataset: the warning "could not apply the known entity/feature split" and the "Dataset build failed" errors ofrun()/upload_featuresgive the whole database message (describe_db_error), not its first line.run(): after an ingestion that ran but did not create the dataset, the log no longer says ingestion was skipped.- Guarded by
tests/test_tm_business_date_cast.py.
[0.3.4.0] - 2026-10-26¶
The dataset created by upload_features follows the process's TimeManager (dataset_time_manager)¶
The dataset that upload_features / run() builds from dataset_view_name= was always CURRENT VALIDTIME, even for a
process driven by a TimeManager.
upload_features(..., dataset_time_manager=False)(last in the signature),run(..., dataset_time_manager=None),roll_out(..., dataset_time_manager=None)andset_process_options(pid, dataset_time_manager=): a process setting (DATASET_TIME_MANAGERinFS_PROCESS_SETTINGS, named-column write, never inMETADATA;None= as recorded). When on and the process has a TimeManager (time_manager=, else the recorded one), the dataset is built withbuild_dataset(time_manager=...)(grouped or not):tm.update(i)moves it, no re-run.- On without a TimeManager: one warning, current dataset as before. A dataset that already exists at CURRENT VALIDTIME (or
in another mode) is not rebuilt silently: a warning names
tdfs4ds.rebuild_dataset(<name>, time_manager=..., replace=True); one already on that manager is left as it is. Migration carries the setting. - Off (default): the
build_datasetcall and SQL are unchanged. Guarded bytests/test_upload_dataset_time_manager.py.
Track when each entity was last confirmed (track_entity_refresh)¶
The ingestion MERGE writes nothing when a value is unchanged, so a feature row's ValidStart is the time of the
last change, not of the last confirmation, and FS_FOLLOW_UP (per run and segment) cannot tell an entity the
source dropped from one a run recomputed.
upload_features(..., track_entity_refresh=False)(last in the signature),run(..., track_entity_refresh=None),roll_out(..., track_entity_refresh=None)andset_process_options(pid, track_entity_refresh=): a process option (None= as recorded). When on, each batch that staged rows records, right after the values and before the follow-up closes, that it produced each of its entities, in an admin tableFS_R_*: entity columns,PROCESS_ID,REFRESH_TIME(the run's valid time),LOAD_TIME,RUN_ID, valid-time period, primary index = the process's. OneFS_R_*table per (data domain, entity key, primary index, placement tag), named byrefresh_table_name(...)= theFS_T_naming rule plus the placement tag, so processes in tables of their own never share one. Created lazily. Not a feature: in no catalog orFS_V_*view; the EFS map lists it as Entity refresh (admin). Fail-open: a failed write is a warning with the full database message and the run staysCOMPLETED.- New optional table
FS_PROCESS_SETTINGS(valid time, key/valuePROCESS_ID, SETTING_NAME, SETTING_VALUE, every INSERT names its columns; created bysetup()/connect()).FS_PROCESS_OPTIONSis not altered: older clients write it with a column-less positional INSERT, so a new column would break theirclose_absent_entities=Truewith Error 3812.read_process_optionsmerges both tables and works when either is missing; later options need no DDL. - Readers:
tdfs4ds.entity_refresh(process_id, entities=, as_of=, history=)andtdfs4ds.entity_refresh_summary(process_id, as_of=)(entities by last refresh; those the latest run did not confirm;not tracked (older client or failed write)when COMPLETED runs left no refresh row). - The parallel planner adds the
FS_R_*table to the write set of a tracked process;remove_processdeletes its refresh and settings rows andfootprintcounts them; migration copies the setting, not the tables;get_process_inforeportsTRACK_ENTITY_REFRESH. - Off by default and byte-identical: the ingestion statements with the option off are asserted against SQL hashes
captured before the change. No
ALTERof any existing table.
Relocate features to their own storage table¶
Features of one (data domain, entity, value type, primary index, partitioning) share one FS_T_* table, so the
parallel planner serialises their processes (one MERGE lock per table), and a table cannot be reorganised for one
process. A feature can now have a table of its own: FS_T_<hash>__<TAG>, with its FS_V_ view.
- Placement: optional table
FS_FEATURE_PLACEMENT(valid time, one row per(DATA_DOMAIN, ENTITY_NAME, FEATURE_NAME)with aTABLE_TAG), created bysetup()/connect()likeFS_PROCESS_OPTIONS, and followed by the naming rule:get_feature_store_table_name(..., table_tag=)appends__<TAG>;register_entitycreates the tagged tables;register_featureskeeps the tag (also across a type change) and warns when the catalog was pointed back at the old table. Nothing used, nothing changed: without a tag or without the table every name and statement is byte-identical (tests compare against SQL hashes captured from the previous version); one cached existence check per store. NoALTERof any existing table. upload_features(..., storage_tag='SALES')starts a process in its own table (refuses a feature already registered elsewhere or shared, with the fix).plan_relocation(features=, process_id=, table_tag=, include_shared=, primary_index=, partitioning=)(read only),relocate_features(plan, dry_run=True),verify_relocation(plan),generate_relocation_script/write_relocation_script(dry run unless--apply): create the table (a clone of the source: same primary index and partitioning), copy the rows with their history, check the counts per(FEATURE_ID, FEATURE_VERSION), write the placement and point the catalog at the new table, rewrite the datasets (EXPLAIN and equal row count, the view is put back if it differs), lineage, statistics, clear caches, delete the old rows last. The plan states rows, perm, free perm (a copy that does not fit is blocking), whether the copy redistributes, and refuses a shared feature, a producer inRUNNING, an unparseable dataset and a view reading the old per-table view. A new primary index / partitioning moves the whole process and updatesFOR_PRIMARY_INDEX/FOR_DATA_PARTITIONING.rewrite_dataset_tables(dataset, mapping=None, dry_run=True): rebuilds a dataset view (snapshot, grouped, history, lags and leads) so that some features read another table, from its own specification; name,DATASET_IDand columns are kept; only the features concerned move. It is the repair ofdataset_reads_stale_table.- Workflow assessment: new storage-level finding
relocation_reverted(rows of a relocated feature in the old table, or a catalog row pointing back at it, left by a client older than this version that registered the feature again); running the relocation plan again moves them with a sequenced merge. The stale-table fix namesrewrite_dataset_tables; the app links findings to a plan, a dry run and the script (it never executes one). - Precondition: every client that runs a relocated process must be at least this version. An older client running a registered feature keeps writing to the tagged table (ingestion reads the catalog); it points the catalog back only when it registers the feature again (type change, or removed and registered anew).
- Migration does not copy placements (the physical layout is a decision of each store).
- Fixed while testing live: the catalog update of a relocation is nonsequenced, in place. A sequenced update leaves the
old table in a closed version of the row, which
FS_V_FEATURE_CATALOGreturns to a session whose temporal qualifier isANSIQUALIFIER; the next run then wrote to both tables.
Workflow assessment: do the dataset features exist?¶
A dataset view whose feature is gone still compiles (EXPLAIN passes), and its feature reads return nothing: an
INNER dataset is empty, a FULL OUTER column is all NULL. The assessment now checks that the features are there.
- Dictionary level, with the cheap
integritycheck:dataset_reads_stale_table(the catalog stores the feature in another table than the view reads; a type change moves a feature on the next run, with no history copy) anddataset_reads_missing_table(the table no longer exists). A feature missing from the catalog staysdataset_reads_removed_feature. - Storage level, on demand, new check
dataset_rows(run by the Scan entity keys button and byassess_workflow;dataset_rows=Falseskips it):dataset_feature_no_rowswhen the table holds no row for the feature's(FEATURE_ID, FEATURE_VERSION)at the valid time the dataset reads. INNER dataset = error, FULL OUTER = warning; the follow-up says whether the process never completed or the rows are gone. The report's Lineage section says whether the storage level ran, and when. dependencies.dataset_table_reads(ddl)returns the storage table and join type of each read (dataset_readsis unchanged);scan_storage_orphans(..., by_version=True)counts per FEATURE_ID and FEATURE_VERSION (default output unchanged).
[0.3.3.29] - 2026-10-23¶
Dataset builder: the Python that creates the dataset¶
- Show Python, next to Show SQL, gives the
tdfs4ds.build_dataset/build_historical_datasetcall that Create dataset makes for the same form, preceded by the connection and the data domain, with one explanation per argument (grain, versions and aliases, join type, valid time or window, lags and leads with the resulting column names). A Copy button copies it. Nothing is run. - No password in the code: the host and user are filled in from the app's connection; the code asks for the
password with
getpass. - Same call, checked: a test runs the generated code and Create against the same stub and compares the arguments, for every kind of dataset and both join types. Live on efs-dev, the code run as written created a view whose SQL equals Show SQL.
Workflow assessment: the lineage network¶
The lineage check only compiled the processes reading an object missing from the data dictionary, and said
nothing of the network's shape; the dead branch of a gap appeared only inside a finding's text. A new check,
network (Lineage dimension, run by default):
- Every view is compiled.
EXPLAINon every process view, dataset view and view the lineage graph holds. Only the root failures are reported (a view whose own inputs compile); the views failing because of it are listed under it, with the processes and datasets it stops. An error the database did not give (no session) is listed under Not verified, never as a broken view. - Is the network in one piece? The connected networks with their processes, datasets, views and tables;
lineage_split(info) when there are several,lineage_isolatedfor a process or dataset with no lineage link. Names of the graph that are not database objects (SQL tokens) are left out, so they cannot join two networks. - Dead branches.
process_not_read(a process no dataset or process reads),detached_branch(views and tables no registered process or dataset reaches), and the branches the lineage gaps leave. - Shown in the report. The Lineage section always carries a Lineage network block (HTML and Markdown), also when nothing is found. The gap check reuses the compilation instead of running its own EXPLAINs.
- Live on efs-dev: 29 views compiled in an 18 s assessment; a dropped table under two views was reported once, at the first view, with the second failing through it; 10 historical datasets built without their lineage showed as isolated networks.
Skills¶
fs-workflow-assessmentdescribes thenetworkcheck and its five finding kinds.fs-datasetdescribes the admin app's Dataset builder (lags / leads and column-name fields, Show SQL, Sample, Show Python, Create).fs-agentdescribes an app started with no feature store selected (Error 3707 before 0.3.3.28, and what 0.3.3.28 changed).
[0.3.3.28] - 2026-10-23¶
Lags and leads in datasets: feature_offsets¶
build_dataset(..., feature_offsets=)/build_historical_dataset(..., feature_offsets=)read a feature at other instants than the dataset's own, as many times as needed. For example{'amount': ['0', '-1 day', '-7 days'], 'churn': ['+1 month']}givesamount,amount_LAG_1D,amount_LAG_7Dandchurn_LEAD_1MO.- Sign:
-is a lag (the past: autoregressive inputs),+a lead (the future: a forecast target),0the instant. A value without a sign is refused. - Modes: every dataset mode: current,
as_of, a TimeManager (tm.update()moves every offset column), and histories. - Units: year, month, week, day, hour, minute, second, and ISO 8601 (
'-P1D'). Months are calendar months (ADD_MONTHS). Fractions of a second are refused, since valid periods are stored to the second. - A lead is NULL until its instant is reached (database time): an open stored period is not an observation of
the future. A current snapshot cannot have a lead: refused with
join_type='INNER', a warning with'FULL OUTER'. The cut-off is real time: on a store whose business dates are in the past, a lead after the last business date reads the last stored value, so end a training window one horizon before it (documented, and done in demo 06). - Latency:
offset_strategy='PIVOT'(default) reads each storage table once for all its offset columns. Measured on Vantage 20 with 8 features × 4 offsets: parse 0.35 s and a 23 ms single-entity lookup, as fast as 8 plain grouped columns.'PER_OFFSET', one sub-query per column, parsed in 31 s on the same columns.- Partition elimination is kept: the shift is applied to the constant side (
ValidStart <= t - L), so every storage read keeps it (EXPLAIN steps read in every mode, single-entity lookups single-AMP). - Catalog and documentation:
- Each offset column is a row of the dataset's feature list.
- The new optional table
FS_<catalog>_OFFSETS(tdfs4ds.get_dataset_offsets()) records its base feature, direction, offset and strategy. rebuild_dataset,restore_dataset_catalogand migration keep the offsets.- Dataset documentation gives each offset column its feature's description, with a fixed sentence on the instant it reads (no LLM).
- Admin app, Dataset builder: a Lags / leads field per chosen feature, with presets, live column names and the
lead check (
POST /ui/dsbuild/columns). - Compatibility:
- Without
feature_offsetsevery statement is unchanged. - Earlier versions cannot read the offsets back from a view: rebuild or restore datasets with offsets using 0.3.3.28 or later.
Column names for feature versions (aliases)¶
selected_featuresaccepts analiason a version:{feature: {'process_id': pid, 'alias': 'spend'}}for one version;{feature: [{'process_id': p1, 'alias': 'amount_old'}, {'process_id': p2, 'alias': 'amount_new'}]}for several versions side by side.
The alias is the column name. Offsets are suffixed to it (spend_LAG_1D).
- Rules: an invalid alias, or one that repeats another column, is refused, never renamed. Without an alias the
names are unchanged.
- Kept everywhere:
- the dataset catalog registers the alias;
- rebuild_dataset, restore and cross-store migration keep it. Migration now rebuilds a dataset from its view
with the source catalog's feature names, so every column keeps its name. Before, version names
<feature>_<view> were lost.
- Documentation: each aliased or version column gets its own version's description, "amount, version computed
by FEAT_V2 — …". Before, such columns got none.
- Admin app, Dataset builder: a column-name field per chosen version; names are previewed as you type, and an
invalid or repeated one is shown in red and blocks the creation.
Demo¶
06 - Datasets in timegains two sections: lags and leads (a lagged dataset checked against the dataset one month earlier, the offsets in the catalog, a training history for a forecast) and a column name of your own (alias).
Workflow assessment: the lineage check says what it checked¶
- The Lineage integrity section used to read "nothing to report" with an empty body. It now says how many processes, datasets, features and dependency links were checked, and lists the gaps it looked for.
- The panel's description names the lineage check.
Fixes¶
- A FULL OUTER dataset with a single sub-query could never be created. The outer select wrote
COALESCE(A1.<entity>)with one argument, which Teradata refuses (Error 3706, checked live). This hit a one-feature dataset, or a grouped one whose features all share one table. It now writesCOALESCE(A1.<entity>, A1.<entity>). HELP COLUMNcuts column names at 30 characters ("Column Name"; seen live: a 46-character name came back as its first 30). The benchmark's schema probe, the query optimizer's column qualification and the plotting column types read "Column Dictionary Name" instead, which is complete.- Admin app connected but with no feature store selected (seen at a site). The
.envset the connection (TDFS4DS_TD_*) but notTDFS4DS_SCHEMA, so every panel built its SQL with an empty database name (FROM .FS_FEATURE_CATALOG) and showed Error 3707, a syntax error that names nothing. The Parameters list displayed the only store found as if it were chosen, because a select always shows its first option. - The default database is used. A logon with a default database (the Connect page's Default database, or
TDFS4DS_TD_DATABASE) that holds a complete feature store now selects it, asimport tdfs4dsdoes aftercreate_context. Another database is left alone. - Panels name what is missing. With no store, every panel reading the store answers "No feature store is selected. Choose one in Settings → Parameters, or set TDFS4DS_SCHEMA …" and runs no SQL. The store list and the execution-log switches still work, since they are how a store gets chosen.
- The startup log warns and the index build is skipped instead of failing.
- Parameters shows "no store selected" as the first option until one is applied, and Apply stores refuses an empty choice.
[0.3.3.27] - 2026-10-22¶
Removals refused when other objects depend on them; ordered removal scripts; lineage gaps¶
- Behaviour change:
remove_process,remove_featureandremove_dataset(..., dry_run=False)now refuse when other objects depend on the object, and raisetdfs4ds.DependentObjectsError. The dependents are: - datasets reading the process's values (
FEATURE_VERSION) or the feature; - process views reading the process view or the dataset;
- what lies downstream of those.
The error lists them in the order they must be removed. The removal is also refused when they cannot be read
(a dataset view that cannot be read, an empty lineage graph). force=True restores the previous behaviour, and
leaves the dependents broken.
Why: a process could be removed while datasets still read its values. Those datasets kept returning the last
values and were never refreshed, and a lineage rebuild then dropped the link.
- New tdfs4ds.plan_removal(objects, purge_values=False, drop_views=False) (dependencies.py):
the removal steps for processes, datasets and features plus everything depending on them, in order.
- tdfs4ds.write_removal_script(plan) writes a .py script that is a dry run unless started with --apply, and
stops at the first failure.
- purge_values=True also deletes the removed versions' stored values.
- Nothing is executed by the plan.
- New FS_REMOVAL_LOG (optional table, created at setup() / connect(); tdfs4ds.get_removal_log()). Every
removal and every refusal is recorded: object id and name, user, time, forced or not, dependents, origin
(api / script / app), outcome. remove_process checks that its catalog rows are gone before recording
COMPLETED, since its decorator prints a failed DELETE instead of raising.
- Removals are verified. After --apply, the script checks, step by step, that nothing is left behind, and
exits with code 2 otherwise. It looks for:
- catalog rows;
- business dictionary rows, process documentation and provenance;
- stored SQL versions;
- vector-index entries: counted by id with Chroma; with the Teradata Vector Store, every collection must be
ready after its refresh.
tdfs4ds.dependencies.verify_removal(plan) runs the check on its own; the dry run shows the same counts
beforehand, and the app's Check what is left runs it after the script.
- Workflow assessment, lineage integrity: new check integrity, area lineage, and a Lineage column in the
executive summary. Kinds:
- dataset_reads_removed_process (named from the removal log when possible);
- dataset_reads_removed_feature and feature_without_process;
- process_view_missing and dataset_view_missing;
- process_reads_missing_object and process_view_broken, judged by EXPLAIN on suspect processes only;
- process_reads_unregistered_dataset and lineage_edge_to_unknown.
Each error names its dead branch and the plan_removal call that removes it.
- Admin app: Operations → Remove objects.
- You choose processes, datasets and features; the page shows the ordered plan, runs a dry run (read-only
counts) and downloads the script.
- It never removes anything; a source-level test guards that.
- A lineage gap's Remove dead branch… link in the workflow assessment opens it with the branch already chosen.
Dataset builder: features computed by several processes¶
- A feature computed by several processes (its versions:
FEATURE_VERSION= the producing process) shows one checkbox per process. - Each line gives the process view, its SQL version, its last run and the datasets already reading it; the tooltip adds the managers and the last SQL change.
- The default is the latest run.
- Ticking several gives one column per version, named
<feature>_<process view>. These are the namesbuild_datasetalready gives a version list ({feature: [{"process_id", "process_view_name"}, ...]}); the builder's create route used to turn every choice into a single id.
Fixes¶
remove_feature/delete_featurefailed with Error 3706 ("expected something between a string ... and the word 'USTOMER'") whenentity_idwas a list printed as text (e.g."['customer']"): the text, quotes included, was pasted into the SQL.entity_idis now normalised: a list, a tuple, a dict (its keys), a comma string, or a list / dict printed as text; a type after a name is dropped. Anything else gets a clearValueError.- The values are escaped (
sql_literal). tdfs4ds.get_process_infois exported at the top level, as the documented removal recipe uses it (AttributeErrorbefore).
[0.3.3.26] - 2026-10-22¶
Workflow assessment: executive summary, exports, demo¶
- Executive summary: the verdict (processes with an error / a warning / information / nothing to report), the
most frequent issues, and a health grid: one row per process, grouped by execution wave (the planner's layering),
one cell per dimension (entity keys, order and time, inputs in time, runs, process SQL, storage and pruning) with
the worst severity,
ok, or not checked. Findings carry the processes they concern (pids). - Admin app: the data-domain box is a list with a refresh button; exports in HTML (standalone page), Markdown
and CSV (
/ui/workflow/download?fmt=html|md|csv).tdfs4ds.write_workflow_report(model, out_path=None, fmt='html'|'md'|'csv')writes the same files. - Demo: notebook
A3 - Assessing the workflow(an entity key with one id too many, the primary index against the source, a downstream process left stale, filters the optimizer can prune on).
[0.3.3.25] - 2026-10-22¶
Workflow assessment: is the pipeline well built?¶
- New
tdfs4ds.assess_workflow(processes=None, data_domains=None, checks=None, entity_scan=True, days=90)(workflow_assessment.py). The consistency checks ask whether the catalogs and the storage line up; this one asks whether the workflow is well implemented. Every check is deterministic (counts, the data dictionary, the lineage graph, follow-up rows, stored SQL hashes): no language model is used, and a test guards the imports. Nothing is changed; every finding names the command that fixes it. - Entity keys. For each entity of two columns or more, the largest storage table is read once per round (distinct entity tuples, with each key column left out, over the whole history and now):
redundant_key_column: the other key columns are unique without this one over the whole history, so it adds nothing to uniqueness and, being in the primary index, prevents a lookup by the real key from using it;key_column_changes_over_time: determined by the others now, but it changed: each change started a new entity and split its history;- the fix names one key for both (
entity_id=[...]), checked unique now; - a dependency observed in the data is not a rule: the finding says how many tuples it covers and asks for the
business to confirm.
Also:
entity_key_superset(one entity key containing another in a domain),entity_column_type_mismatch(an entity column typed in different families across tables: joins convert),storage_skew(DBC.TableSizeV). - Order and synchronisation, per direct dependency of the process DAG (lineage), business date by business
date:
ran_before_upstream,upstream_recomputed_after(the process is stale),completed_on_failed_upstream(failed orCOMPLETED,0 rowsupstream),upstream_missing_at_dateandahead_of_upstream(same timeline only),mixed_time_modes,different_time_drivers. Real-time pairs are only checked for overlapping runs (different cadences are normal). - Point-in-time leakage: a TimeManager-driven process reading a dataset built at the current valid time
(
point_in_time_leak), frozen at a date (fixed_as_of_input) or on another TimeManager (input_other_time_manager). - Primary index and partitioning against the sources (check
distribution). Each entity column is followed through the view SQL to its source column (sql_context.column_origin/column_lineage, unchanged columns only), the sources' primary indexes and partitioning are read from the data dictionary, and the staging step of ingestion is EXPLAINed with the current and the aligned index (EXPLAIN only): pi_not_aligned_with_source: a warning when EXPLAIN shows the rows redistributed now and local with the index of the largest source, nothing when it shows no difference;entity_pi_inconsistent,partition_not_aligned_with_source,fs_partition_not_on_filter;segments_rescan_source: a FilterManager whose columns do not follow the largest source's partitioning;- pruning judged in two steps: the filter names a partitioning column (dictionary), then the plan with the
manager's value known (
DYNAMIC EXPLAINunderDynamicPlan=SYSTEMX,utils.query_band. forced_incremental_planning, session band restored).segment_filter_defeats_pruningandtime_filter_defeats_pruning: the filter matches but the plan still reads every row, so the view SQL prevents pruning.time_filter_by_join/time_filter_not_on_partitioning: favour a scalar filter on the date partitioning column (ACC-scored views excepted, Error 6881); - recommendations are per entity: the index is part of the storage table name, and changing it is a re-registration (registering a view again keeps the recorded index).
- Runs:
segments_missing,segment_failed,hollow_run,stuck_running. Process SQL:sql_drift(live SQL different from the stored version) andhistory_mixes_versions. - A check that cannot run (lineage graph empty, DBC unreadable, versioning off) is listed in
unverifiablewith its reason, never reported as passing. - Admin app: Operations → Workflow assessment (
/ui/workflow, entity scan on demand over SSE/ui/workflow/scan, CSV export/ui/workflow/download), and a Workflow assessment section in the Report. - New skill
fs-workflow-assessment. process_sql.chain_drift()is now the one drift computation (the feature catalog panel calls it).- Found while verifying live: a FilterManager with a
BUSINESS_DATEcolumn logs only its other columns inAPPLIED_FILTER, so segments are counted per run when the logged filter repeats; and run numbers fromexecution_run_idsfollowRUN_IDtext, not time, so "the latest run" is chosen by start time. EXPLAIN names a table by its correlation name (DB_SOURCE.A in view ...). A static plan shows an all-rows scan of a source filtered on its own partitioning column (FilterManager or TimeManager value = a parameter, request below the incremental-planning cost threshold); with incremental planning forced, the same views read 304 or 365 partitions, and a PTI source "a time range of" it. A filter on one partitioning level is enough. Later plan steps name the table as a grouping column while scanning a spool: only steps reading FROM it count.process_storage_tablesmatches features by name within a domain, so it also returns another entity's tables; the check keeps the process's own entity only.
[0.3.3.24] - 2026-10-21¶
Skills reviewed against the code¶
- Fixed:
td-explaincalledtdfs4ds.document_process(it istdfs4ds.genai.document_process);td-analytics-workflowsaidconnect(create_if_missing=True)does not exist (it does);fs-setupofferedSTORE_FEATURE='UPDATE_INSERT'(broken, keep'MERGE') and describedFEATURE_STORE_TIMEas time travel only;fs-lineage-graphdescribedrebuild_lineage_graph()as a wipe (it is diff-based);fs-tdstone2overwrotetdfs4ds.SCHEMAinstead ofconnect()and linked into the package source; aca-plotclassifier example carried placeholders the renderer does not fill. - Added: failure handling (
on_failure,check_upstream,check_upstream_health,wave_on_failure) infs-rollout;retrieve_batchandrun_benchmarkinfs-dataset; storage statistics,cleanup_temp_tablesand process SQL versions infs-inspect; Chroma collections per store andindex_freshnessinfs-vector-store; the MCP settings infs-agent;Demos:lines naming the notebooks; the README lists every skill. - New test
tests/test_skills_consistency.py(offline): everytdfs4dscall in a skill's code blocks must exist and take the arguments shown,entity_idis never a dict, classifier examples use only filled placeholders, the README lists every skill, and links surviveinstall_skills().
[0.3.3.23] - 2026-10-21¶
remove_feature can be scoped to one process¶
- New
process_id=parameter. A feature is the triplet (data domain, entity, name) and has one catalog entry whatever the number of processes computing it.remove_feature(name, entity_id, data_domain, process_id=pid)removes the feature as produced bypid: - computed by
pidalone: removed entirely, as before; - also computed by another process: the catalog entry is kept, and only
pid's documentation of the feature goes (itsFS_PROCESS_DOCUMENTATION_FEATURESrows, the business-dictionary column ofpid's view, the provenance and vector-index entries). The other process's documentation is untouched. - A
pidthat is no longer registered is accepted: the decision rests on the other producers. When the producers cannot be read, nothing is removed (Noneis returned and the error logged). - With
process_idthe call returns{"mode": "full" | "process", "others": [...], "listed": bool}. - Unchanged without
process_id: the feature is removed for every producer andNoneis returned. - Removing a process with its features is documented in Inspect & monitor: impact first (downstream objects),
then
remove_feature(..., process_id=pid)per feature, thenremove_process.
[0.3.3.22] - 2026-10-20¶
Entities a process no longer produces lose their validity (close_absent_entities)¶
- New process option.
upload_features(..., close_absent_entities=True)declares that every laterrun()/roll_out()of the process ends the validity of the stored entities its view no longer returns. Before, a run only wrote the entities present, so a customer that left the source kept its last values open (UNTIL_CHANGED) and current datasets kept showing it. - The rule is the MERGE's. A run states the truth from its valid time t onward (the TimeManager date under
roll_out,FEATURE_STORE_TIME, else the run's real-time instant): absent entities have no value from t. The period before t is kept, an entity that comes back gets a new period (the absence is a gap), a re-run at the same date changes nothing, and an ordered replay gives the same history. As for the values, re-running an earlier date alone rewrites everything from that date: replay from the first date (verified live: the ingestion MERGE already rewrites the later periods of the entities present). - Safety. A batch that staged 0 rows closes nothing (an empty or failed upstream must not end every entity). A
failed close records the batch
FAILEDwith the database error, andon_failureapplies. With a FilterManager a segment closes only its own entities: every filter column must be an entity column, else the registration is refused (nothing registered,Nonereturned). - Stored in a new optional table,
FS_PROCESS_OPTIONS(valid time, one row per process; the place for later per-process options), not in the descriptiveMETADATAcolumn. It is created atsetup()and byconnect()when missing; a refused CREATE is one warning.FS_V_PROCESS_CATALOGdoes not join it, so its text and the registration SQL are unchanged, and a store without the table runs with the option off. - API.
tdfs4ds.set_process_options(pid, close_absent_entities=...)changes the option of a registered process;run(..., close_absent_entities=)androll_out(..., close_absent_entities=)override it for one call (None= as recorded).get_process_inforeturnsCLOSE_ABSENT_ENTITIES;remove_processdeletes the row;migrate_objectscarries the option over. - SQL shape (Vantage 20). A sequenced
DELETErefuses every subquery (Error 9347 forNOT EXISTS,NOT INandIN), so the close stages the absent keys in a volatile table (NONSEQUENCED VALIDTIME SELECT) and runs the sequencedDELETEjoined to it, over the MERGE's own period clause. - Backward compatible. Off by default; ingestion SQL unchanged when off. A run reads the option with one fail-open query.
[0.3.3.21] - 2026-10-19¶
Charts: "the latest week", historian tables read from their column documentation¶
- Relative windows. "the latest week", "last 7 days", "past 2 months", "la dernière semaine", "les 3 derniers jours", "depuis 2 jours" zoom a chart on that span, ending at the database's current time (months and years go back on the calendar). The phrase is no longer taken for a valid time: the answer used to start with "Could not read the date 'latest week'".
- Historian tables without a
DEEP_LAYOUTsection. A table whose columns are documented (a timestamp, the sensor name, the measured value, identifiers such as the drawing tower) is drawn as a historian table: the roles are read from the column types and descriptions (infer_historian_layout), stated in the answer, and used for that chart only (plotting.using_layout).set_deep_table_documentationstill makes them permanent. - Sensors and identifiers found by their wording. "the drawing length sensor" finds the tag
DRAWING_LENGTH,DrawingLengthorDrawing length (m)among the tags of the table (match_tags); several equal matches are asked. "per drawing tower" finds the id column described as the drawing tower. A historian table is found from its documentation, or from the word "historian" when there is only one. - The chart request survives "I could not tell what to plot". That answer now keeps the request, so a reply such as "it is in the historian" completes it; it lists the documented historian tables and their tags. Before, the reply was classified on its own and could be sent to the MCP tools.
- MCP tools that cannot be imported.
No module named 'langchain_core.messages.content'(a 0.x langchain-core under 1.x packages) is reported as such, with the installed versions and the%pip install -Uline, in the chat answer and incheck_configuration.setup.pyrequireslangchain_core>=1.0.
[0.3.3.20] - 2026-10-16¶
launch_app asks for the feature store; query band switch in Parameters¶
- Parameters → Execution log: query band and history: switch
QUERY_BAND/EXECUTION_RESOURCESfor the app, with the tables created and a check that this user's runs can be measured. A store not selected is now said as such ("choose one in Parameters") instead of "disabled". launch_appasks for the feature store whenTDFS4DS_SCHEMAis not set (lists the stores visible with the logon; the choice can be saved) and checks the query band and DBQL with the same logon (a note, nothing asked). One check,query_band.query_band_capability, also behindcheck_configuration.find_feature_stores(run=)reads through another connection.
[0.3.3.19] - 2026-10-16¶
Execution log, consumption history, defective datasets¶
- Resource usage kept beyond DBQL's retention. Two optional tables, created by
setup()/connect()when missing:FS_EXECUTION_RESOURCES(one row per execution: the follow-up columns, the summed DBQL figures andPROCESS_VERSION, the process version in force at the execution's valid time) andFS_EXECUTION_STATEMENTS(one row per statement).tdfs4ds.harvest_execution_resources()copies, in the database, the executions DBQL has finished writing; it also runs at the start ofrun()(at most every 10 minutes, never fatal). New readersread_execution_resources(process_ids, days, as_of)(stored + live join; says when DBQL is unreadable or logs nothing),execution_statements(execution_id)and the pureprocess_run_statistics(frame, by_version=).TDFS4DS_EXECUTION_RESOURCES=falseturns it off. - Runs, not follow-up rows. A FilterManager run writes one row per segment and
roll_outone per business date; frequency and per-run figures now group them (sameRUN_ID, rows within 10 minutes). The Report's refresh cadence uses the same rule (process_followup.execution_starts). - Admin app, Operations › Execution log: each execution with its process version, segment, status (whole error), duration, CPU, skew, I/O and spool; its statements on selection; per-process and per-version consumption with a chart over time; XLSX export. Maintenance › Execution history copies from DBQL on demand.
- Feature catalog card: Runs and consumption for the 30 days up to the catalog time viewed; the versions
table marks the version in force at that time and gives each version's median run and CPU. The export adds
PROCESS_VERSION_AT,RUNS_30D,RUN_FREQUENCY,MEDIAN_RUN_SEC,MEDIAN_RUN_CPU_SECto Processes. - Defective datasets: in the Dataset builder's list, a failed sample marks the dataset Defective with its
cause (its view dropped while the catalog still lists it, an object it reads gone, access refused, no longer
compiles); Check all finds them without reading data. Remove from catalog (admin view, primary store)
previews then removes. New
tdfs4ds.remove_dataset(name, drop_view=False, dry_run=True): catalog rows (history kept), lineage, business documentation, provenance and vector-index entries; the view only on request, and nothing to drop when it is already gone.drop_dataset()gainsclose_lineage=. followup_resource_usage_sql(process_ids=[...]); withdays=,EXEC_SEQis now numbered before the time bound (an older run's rows were renumbered and could be paired with the wrong executions). Windows over 99 days no longer produce an invalid interval literal.
[0.3.3.18] - 2026-10-16¶
launch_app asks for missing connection settings¶
launch_app()checks the database connection before starting the app: host, username, password (or a JWT token) andTDFS4DS_TD_ENCRYPT, resolved the way the app reads them (theenv_filegiven, which wins over the environment, else the first of./.envand~/.tdfs4ds/.env), and tests the logon with the raw driver (the notebook's connection is untouched). When one is missing, malformed or refused, and someone can answer (a terminal or a notebook), it asks: type them (password hidden; the username and password are asked again after a refusal), use another.envfound, or start without them (Connect page). Typed values can be saved for the next launches in a.envfound or a new one (default~/.tdfs4ds/.env, created readable by the user only), edited line by line; the password is asked separately. Newask=(None = when someone can answer, False = never) andcheck_logon=.tdfs4ds.connection_settings(env_file)reports what the app would use and where each value comes from, never a secret. The returnedconnectionlists the variables typed and where they were saved.- Fixed: the app crashed at start when no feature store was selected (
TDFS4DS_SCHEMAunset): the background index build receivedNoneas the store. It now builds nothing and says to choose the store in Parameters. - The logon check (
utils.reconnect._credentials_probe) reports the driver's whole message (it kept the first line, where the cause of an Error 169 is not).
[0.3.3.17] - 2026-10-16¶
Fixed¶
TDFS4DS_TD_ENCRYPTbroke the connection on recent drivers. teradataml'screate_contextupper-cases every string connection parameter (forTMODE), soTDFS4DS_TD_ENCRYPT=falsereached the driver as"FALSE", which teradatasql 20.0.69 refuses:[Error 169] Unable to parse JSON connection parameters ... trying to unmarshal "FALSE" into bool, and the app could not start. The setting (and the Connect page's Encrypt field) is now read byutils.reconnect.encrypt_setting(true/false/1/0/yes/no/on/off, any case) and passed tocreate_contextas a bool and to the raw driver as'true'/'false'. Any other value is refused with a message naming the variable. Workaround on older versions: remove theTDFS4DS_TD_ENCRYPTline (encryption off is the default).
[0.3.3.16] - 2026-10-16¶
The agent searches documented data outside the feature store; the documentation exports and imports back¶
- Inside or outside the feature store, one rule.
tdfs4ds.documentation_export.object_scopedecides it: process views, datasets, Time/FilterManagers andFS_*/*_HIDDENobjects are in the feature store; every other documented table or view is outside it (documented data, not governed features: no data domain, no entity, no refresh history, column relationships not certified). The agent and the export use it. - The consumer agent finds tables and columns by meaning. "Which table contains data about stock prices?",
"is there any data dealing with transactions?": the vector hits on documented objects outside the store were
dropped until now (only feature, dataset and process names passed the grounding filter). They are now reported
under their own heading, Outside the feature store, written by code (never by the LLM), with the matching
columns and the feature processes that already read each object (lineage). The feature-matching LLM never sees
them, so a source column is never presented as a feature. A question about features that found some gets one
line pointing to the outside data; a question about data, or one that found no feature, gets the full section
after the In the feature store part. Such a question is routed to SEARCH whatever the classifier said,
unless it names a documented table or column; "what is this table?" / "lineage of this table" then refer to the
best match. DEFINITION of a documented source table or column opens with the same fixed sentence. Without a
vector index, outside objects are matched on words. Vector hits now carry
DATABASE_NAME. tdfs4ds.export_documentation(path, scope=, databases=, objects=, as_of=, include_missing=True)writes the business dictionary as XLSX (About / Objects / Columns / Sections, Excel tables) or one HTML page: every documented table and view, its description, columns and sections, each row tagged with its side and kind, with the provenance of each text. What is not documented yet is listed too, in yellow (STATUS='missing'): process views, datasets and lineage tables/views without a description (existence checked in DBC), their undocumented columns in table order, and the standard sections a dataset lacks. Admin app: Documentation › Coverage › Export the documentation (scope, XLSX, HTML).tdfs4ds.import_documentation(path, dry_run=True, author=)writes the filled-in workbook back: only new or changed texts, through the hand-documentation API (MANUAL provenance, kept by LLM runs; a feature's text also reaches the process documentation and the datasets). An emptied cell deletes nothing; read-only stores, managers, internal objects and process-view sections are skipped with a reason. Live: an unedited export re-imports as 250 unchanged texts and nothing to write.
[0.3.3.15] - 2026-10-15¶
Several feature stores in the admin app; a status bar; Chroma indexes every store¶
- Status bar. A bar at the bottom of the admin app names the feature stores the session reads and what runs in
the background, with its progress: the vector index build, Maintenance runs, documentation, migration,
evaluation and benchmark runs. details lists the jobs of the last minute, with the whole error of a failed one.
New route
GET /ui/status; the registry istdfs4ds.serve.activity(no database access). - The index is built in the background. At startup the server used to build the vector index before opening
its port, so the first operations waited with nothing on screen. It now answers at once and builds in the
background (one job per store; a second request for the same store is queued, never run alongside). The
Parameters actions that rebuilt the index (store, vector store, embedding model) return at once too, and their
routes no longer block the server while they run. A background build reads the business dictionary through a
raw teradatasql connection of its own (from the
TDFS4DS_TD_*settings): reading on teradataml's single shared connection while the app served requests broke that connection for the rest of the process (seen while developing this release). Without those settings, and on the Teradata Vector Store backend (which works through teradataml), the build runs in the foreground as before. - Several feature stores in Parameters. Pick the primary store (read-write) and any number of stores to read
from (read-only,
connect(databases_readonly=...)). A store that cannot be read is named with the reason, and the panel says how many features exist in several stores (the assistant treats each as one, the primary's). Stores set withTDFS4DS_SCHEMA_READONLYare now validated at startup. - Chroma: one set of collections per store.
tdfs4ds__<database>__objects/_sections/_columns. Switching the primary store no longer wipes and re-embeds the index (it used to runforce_rebuild=True); a search covers every attached store and tags each hit withFEATURE_STORE, as the Teradata Vector Store backend already did. A read-only store is read through its publicFS_V_BUSINESS_DICTIONARY_*views; nothing is written to it.build_vector_index(force_rebuild=False, progress=None, stores=None):storespicks the stores (default: every attached one),progressreceives each step. Upgrade: the first build re-embeds the primary store once; the old collectionstdfs4ds_objects/_sections/_columnsare left unused (delete them withchromadb.PersistentClient(path=tdfs4ds.CHROMA_PATH).delete_collection(name)when no older version shares the index). - Chroma: an indexing date per store, and removed rows leave the index. Each build records, per store and
collection, the database's time when it started (in a small
tdfs4ds__index_statecollection) and the embedding model. The next build of the primary store then reads only the documentation written since (ValidStartat or after that date) plus the ids of every current row, so removed documentation is dropped from the index; until now nothing ever left it. Live: an unchanged store re-reads nothing, one new row embeds one row, its deletion removes it.tdfs4ds.agent.vector_index.index_freshness(store)says whether the index is behind (up_to_date/behindwith the number of rows /never/model_changed/unknown) without embedding or scanning Chroma; Maintenance shows it for each store. Another embedding model, aforce_rebuild, or a read-only store (its public views carry noValidStart) use the full row-by-row comparison, as before. - Maintenance re-indexes the stores you tick, including a store that is not attached (read through its views). force vector rebuild re-embeds only those. The Teradata Vector Store backend still indexes the primary only.
- Run history and documentation per store. With read-only stores attached, the Report, EFS map, consistency
checks, feature-catalog export and documentation coverage used to read runs and documentation from the primary
only, so another store's processes showed as never run and its features as undocumented. They now read each
store's
FS_V_FOLLOW_UP,FS_V_PROCESS_DOCUMENTATION_*,FS_V_DOCUMENTATION_PROVENANCEandFS_V_MANAGER_REGISTRY(new capability probes at attach; a store without one is skipped for that read). The threeFS_V_PROCESS_DOCUMENTATION_*views were declared but never created;connect()now creates them. With a single store every read keeps its SQL. Past valid times, stored SQL versions and the EFS map's metadata objects stay primary-only. - Writes stay primary-only. Coverage and Objects list another store's objects with 🔒; they cannot be
documented or edited from the app (the server refuses it too). The Execution plan lists and plans the primary's
processes only (
run()executes those alone). - Fixed: picking a source in the Migration panel left that store attached for the rest of the session, so every later read crossed stores.
- SageMaker start script: the settings reach notebooks and terminals.
sagemaker_lifecycle_script()used to give the env file to the app only. It now links it as~/.tdfs4ds/.env(read byimport tdfs4dsin every notebook) and writes~/.tdfs4ds/env.sh, quotedexportlines sourced from~/.bashrc, for terminals. Both are readable byec2-useronly; nothing goes to/etc/profile.d, which every account can read (and which a kernel never reads).share_env=Falserestores the previous script.
[0.3.3.14] - 2026-10-15¶
Documentation edits no longer fail with Error 3807 on a staging table¶
- Fixed:
upload_business_dictionary_objects,_columnsand_sectionsstaged their rows in a volatile table and read it back with a second statement. A volatile table exists only in the session that created it, and the admin app's threads share teradataml's pooled connection, so the MERGE could run in another session (or after a reconnect) and fail with[Error 3807] Object '<user>.BIZ_DICT_OBJECTS_STAGING' does not exist. An edit made in the app failed while the same upload from a notebook worked. - Each upload now stages in a regular table named
tdfs_tmp_<12 hex>in the store schema and drops it afterwards;cleanup_temp_tables()already recognises that name. The MERGE statements are unchanged. The account needs CREATE TABLE in the store schema, as for feature registration.
[0.3.3.13] - 2026-10-15¶
Removals clean up after themselves; process versions ignore managers; the Connect page says why¶
- Managers are not versions. A TimeManager / FilterManager replaces its own view at every
update(). Manager views are never steps of a version, and every manager the chain reads is replaced by a placeholder before hashing, so moving to the next date or segment, or repointing a process onto another manager, makes no new version. - Removing a process or a feature removes what it left behind.
remove_processalso removes:- the provenance of its documentation;
- its vector-index entries: Chroma by id. The Teradata Vector Store collections are refreshed, because their
embeddings are keyed by row position;
refresh_index=Falseskips that; - its stored SQL versions;
- its FilterManager / TimeManager catalog rows;
- the lineage edges of its view once the view is gone. The new
drop_view=Truedrops it; a view kept after its process is removed is still a real object, so its edges stay.
remove_featurealso removes its feature documentation (process feature rows, the business-dictionary column of each producing process view, its provenance) and its vector-index entries. A feature is not a node of the lineage graph, so there is nothing to remove there.- Unchanged: the stored values (
FS_T_*) are kept, as before. - Module: removal.py.
- Two process-version bugs found by the new demo notebook (0.3.3.12 is affected):
- Version 1 never closed. A version captured in real time runs to 9999-12-31. The next capture at a
business date deleted only up to 9999-01-01 and left a sliver of it, so its period read "valid to
9999-12-31". Deletes and updates now run up to
UNTIL_CHANGED, as the ingestion MERGE does. - A spurious version 2 at the first roll-out. A TimeManager attached at registration is recorded only after the first capture, so the neutral text changed between the two captures. The managers now come from the chain itself as well as from the catalog. Object names are compared unquoted.
- Demo notebook
10b - The SQL beneath a process. - What it shows: an upstream view edited between two roll-outs; versions 1 and 2 at their business dates;
the diff; the feature version unchanged; a history with its
_PROCESS_VERSIONcolumn; the export columns; the backfill. - Notebook 10: removes its second process with
remove_process(drop_view=True). - The Connect page showed only
TDML_2000 Failed to connect to Teradata Vantage. teradataml raises it from the driver's error, which names the fault (password, LDAP, host, encryption). The page now shows the whole chain. - Credentials typed in the Connect page now serve the automatic reconnect. It reads only
TDFS4DS_TD_*, so a session opened from the form could not be re-opened after a drop. They are kept in the server process's environment and never written to a file.
[0.3.3.12] - 2026-10-15¶
Feature values of given entities; process versions from the SQL beneath a process¶
- The values of a feature for given entities. "What is sum_Transaction_Amount for customer 33357?" and "show the features of TICKER NVDA" are answered by the VALUES skill with a table written by code, the valid time it was read at, and each feature's process version.
tdfs4ds.feature_values_for(features, entity_values, entity=, data_domain=, as_of=)andtdfs4ds.entity_profile(entity, entity_values, ...)(every feature of the entity, at mostAGENT_LIST_MAX_ITEMS).- The lookup compares the key columns untrimmed with typed literals, so Teradata reads one AMP and one partition (checked in EXPLAIN). Values the store does not hold are named in the answer, never guessed.
- Process versions. A process keeps its PROCESS_ID (it belongs to the view name), but the SQL beneath the view - the view and every view it reads, down to the source tables - can change. That SQL is now versioned.
- A new optional temporal table
FS_PROCESS_SQL(viewFS_V_PROCESS_SQL) stores the chain of each version: one row per view and 32,000-character chunk, with a hash per view and for the chain. upload_featuresandrun()read the chain and compare its hash with the stored one. A change (whitespace, case and comments ignored) records a new version and moves the process catalog'sPROCESS_VERSIONto it. Inside aroll_out, only the first date checks. Measured: about 1 s per run when nothing changed.- Valid time: a version is stamped at the valid time the process writes its values at
(
FEATURE_STORE_TIME/ the TimeManager's date, else the run's real-time pin), like the catalog row and the values. The version that computed a stored value is the one in force at itsValidStart. - The feature version is unchanged:
FEATURE_VERSIONin theFS_T_*storage tables stays the PROCESS_ID. Datasets, histories and plots read the same rows as before. - API:
tdfs4ds.process_sql_versions(pid),tdfs4ds.process_sql(pid, version=None, as_of=None),tdfs4ds.process_sql_diff(pid, v1, v2),tdfs4ds.capture_process_sql(all_processes=True, dry_run=True).tdfs4ds.PROCESS_SQL_VERSIONING = False(envTDFS4DS_PROCESS_SQL_VERSIONING) switches it all off:upload_featuresandrun()then send exactly the statements of 0.3.3.11. - Histories know the versions.
build_historical_datasetwarns when the window spans several process versions of a feature, andtdfs4ds.history_versions(dataset)lists them.process_version=Truealso creates a companion view<view>_PVwith a<feature>_PROCESS_VERSIONcolumn per period; the dataset view itself is unchanged. The chat states the versions of a history dataset, and a history chart notes them. - Feature report (admin app and export). The process detail lists its versions (validity, changed views,
runs that used each, who captured it). A version opens its stored SQL, two versions can be compared (diff), and
a SQL changed since the last capture is flagged with a Capture now button. At a past valid time the SQL
shown is the version in force then. The export adds
SQL_VERSIONSandLAST_SQL_CHANGEper process and, at a past valid time, the stored SQL.
Upgrading an existing feature store¶
connect()(orsetup()) createsFS_PROCESS_SQLand its view when missing. A user who may not create them gets one warning and versioning stays off for that session; nothing else changes. No existing table is altered.- Existing processes have no stored SQL. Their first capture (next
run()/upload_features, or the backfill) stores the current SQL under the catalog's currentPROCESS_VERSION, without changing it. Backfill:tdfs4ds.capture_process_sql(all_processes=True, dry_run=False), or Maintenance > Process SQL versions in the admin app. No history exists before that capture: earlier runs show as "before versioning". - An older client sharing the store ignores the table. A SQL change made through it is recorded at the next capture by a newer client; the feature report flags it meanwhile.
- A migrated process starts again at the target store's own first capture (versions are not copied).
[0.3.3.11] - 2026-10-15¶
Entity values and feature extremes in the chat; no database lock in the app¶
- The values of an entity. "Give me 5 TICKER values" and "What are the TICKER available?" returned the
list of TICKER's features, then the reference ticker NVDA. A new VALUES skill answers with how many
distinct values the entity has in the feature store and a random sample (
tdfs4ds.feature_values.entity_sample: oneCOUNTover the union of the distinct keys of its storage tables, thenSAMPLE n, all in the database). - The ends of a feature's distribution. "Which customers have the highest sum_Transaction_Amount?", "top 5
...", "outliers of ..." give the statistics (min, quartiles, max, mean, std), the n highest and n lowest
entities, and the outliers by Tukey's rule with the n farthest from the median
(
feature_values.feature_extremes: one aggregate pass, thenTOP nreads). A distribution chart of a feature lists the same under the chart. - A feature name shared by several entities or domains: the entity named in the question, even in plain words
("customers" for
CustomerID), the domain written in it, then the conversation's domain choose; otherwise the agent asks with a numbered list and the reply ("2", "CustomerID") completes the request. - Routed by code, so the classifier cannot send these questions to the feature list.
- "What could you tell me about DB_SOURCE.MARKET_PRICES" answered that nothing was documented. The business
dictionary lookups matched the whole
db.nameagainst the object name; they now split it and keep the rows of that database (a bare name still reads every database). - "Which features are available for CustomerID?" after a turn on another domain answered "the data domain CustomerID" with that other domain's features, then "0 features". A name that is not a data domain of the store no longer labels the answer, and a domain the question does not write, which does not hold the entity named, gives way to the entity: its features in every domain holding it.
- Admin app: the database lock of 0.3.3.10 is removed (Denis: the database deals with concurrency). The configuration check keeps its streamed rows (each check shows as it finishes, with its duration) and its corrected hint for a broken session. The errors that motivated the lock came from the client side - one teradataml connection shared by the app's threads, replaced by a reconnect under the others - and can come back under concurrent use; one connection per request thread is the follow-up.
[0.3.3.10] - 2026-10-14¶
Superimposed curves aligned on their mean or median; one database request at a time in the app¶
- Align the level on the mean or the median.
rebase=(onplot_aligned_periods,plot_historian,panel) now takesTrue/'first'(100 at the first value, as before),'mean'or'median'. The last two give 100 at the mean / median of each whole extracted curve, margin included. The x alignment (days from each period's start) is independent:rebase=Falsealigns on x only. - How: Teradata has
MEDIANas an aggregate only, and not in a sub-query either (MEDIAN(...) OVERis Error 3707,MEDIANin a derived table Error 3706, both measured). So the reference of each curve is grouped once into a small volatile table and joined back to the staging table (plotting.rebase_update_sql). A curve whose reference is 0 is left out. Checked live: every curve's median reads back exactly 100. - In the chat: "align them on the median", "the mean instead", "sur la médiane". A short reply that only changes the previous chart (reference, "raw values", "separately", a window) is now routed to the chart by code; the classifier sent the first two to OUT_OF_SCOPE and EXPLAIN.
- Admin app: one database request at a time. teradataml has one process-wide connection, and the app serves
requests from several threads. Two requests on it at once, or one reconnecting under the others, failed with
N is not a valid connection pool handle(Benchmark dataset list) or'NoneType' object has no attribute 'cursor'(configuration check: Temporal and Query band reported as failing although they work). - The fix: a lock (
serve/db_access.py). Every/ui/*and/v1/*request takes it in a middleware; every streamed job (chat turn, benchmark, maintenance, evaluation, migration, documentation, storage counts) takes it in its worker thread for the whole job. A chat turn takes it before the turn lock, the order the dataset builder uses. - Waiting: a request that cannot get it within
TDFS4DS_SERVE_DB_WAITseconds (default 60) is answered "The database is busy (benchmark run, 42 s)" instead of hanging; a chat turn says it is waiting. - Checked live: 8 concurrent panel requests, twice, all answered with no error.
- Not covered: the Gradio chat at
/chatbotand the LangServe routes. - Configuration check: when the database never answered (a broken client session), the Temporal and Query band hints now say so instead of asking the DBA to enable Temporal.
langchain-mcp-adaptersis named in theserveextra (it was already a dependency).
[0.3.3.9] - 2026-10-14¶
Superimposed periods in one chart¶
- One chart, not one per stock.
plot_aligned_periodsnow draws every stock and every period of the feature in a single chart per tag. Each curve is one stock and one period (legendNVDA | 2017-07-03), each indexed to 100 at its own start. Every curve is drawn: the series cap is raised to stocks x periods. - Before: one panel per stock, so the stocks could not be compared on the same axes.
- One panel per stock remains available:
per_id=True, or "separately" / "one chart per stock" in the chat. - TD_PLOT has 10 colours: beyond 10 curves, colours repeat, and the legend labels tell the curves apart.
- A legend with many curves no longer covers them (every chart, not only this one).
- TD_PLOT's limits: it puts the legend inside the plot area only (
'outside'is Error 9153). It takes no legend font size or column count, oneCOLORfor every series, and no NULL value (measured on Vantage 20). - Up to
tdfs4ds.PLOT_LEGEND_INLINE_MAXcurves (6): unchanged. - More curves: the image grows taller until every entry fits. On a numeric x axis (the superimposed
periods), the x range is extended on the right by the legend's width, sized from the longest label; the
legend sits there,
'upper right', clear of the curves. A time axis takes noXRANGEand no empty point, so its legend stays'best'in the taller image. - Above
tdfs4ds.PLOT_LEGEND_MAXcurves (40): no legend, and a note says why. - New pure helper
plotting.legend_plan(...). A panel'slegendcan be a TD_PLOT location string. - "Superimpose them" after a windowed chart redrew the whole history as base 100, without the window or the periods. The classifier's sticky object name made the reply look like a new request. A modifier-only reply is now matched on the words typed, so it redraws the previous chart with the periods aligned.
[0.3.3.8] - 2026-10-14¶
Duplicate keys shown when a process is refused; datasets with a DATE entity key¶
- A process that returns duplicates now shows them. The refusal (
Invalid process: the process generates duplicates) is now aDuplicateEntityKeysError, still aValueError. - Its first line gives the number of duplicated key values (
roll_outprints only that line). - Its message shows the rows of up to 3 duplicated keys and names the columns whose value differs between them. When nothing differs, it says the view repeats rows (a join fan-out, a UNION ALL, a missing DISTINCT).
- A query listing every duplicated key of the process view is included.
- On the exception:
e.sample(DataFrame),e.differing,e.query,e.n_keys. - Before: only the count, and in
DEBUG_MODEan unexecuted query text. - Exact copies: they never counted. The check reads a SET volatile table, where exact copies are already dropped, and the listing query counts distinct rows to match.
- Fixed: building a dataset failed when an entity key is a DATE (Error 3707 "between the word 'YYYY' and '-'", seen from the chat).
- Cause: the catalog registration pasted the column type,
DATE FORMAT 'YYYY-MM-DD', between quotes without escaping it. - Fix: every value of those catalog writes is now escaped.
- Recovering a dataset that failed this way: its view and catalog row exist without entity and feature
rows. Repair it with
tdfs4ds.restore_dataset_catalog(dry_run=False, datasets=['<name>']). - Fixed: a dataset name given as
'database.view'was taken whole as the view name. This applies toupload_features(dataset_view_name='DB.DS_X'),build_datasetandbuild_historical_dataset. - Effect: the view was created as
DB."DB.DS_X"(one name containing a dot) and registered asDB.DB.DS_X. - Fix: the database is now taken from the name; a different
schema_namegiven as well is refused. - Cleaning up a dataset created this way:
- Run the process again to build
DB.DS_Xproperly. - Drop the old view with
DROP VIEW DB."DB.DS_X". - Close its catalog row with
tdfs4ds.restore_dataset_catalog(drop_orphans=True). Run it with the defaultdry_run=Truefirst, then withdry_run=False.
- Run the process again to build
[0.3.3.7] - 2026-10-14¶
A local Teradata MCP server from the tdfs4ds credentials; the admin app on SageMaker Studio¶
tdfs4ds.start_mcp_server(port=8001, profile='eda')starts the Communityteradata-mcp-serveron127.0.0.1, logged on withTDFS4DS_TD_HOST/_USERNAME/_PASSWORD(database:TDFS4DS_TD_DATABASE, elsetdfs4ds.SCHEMA). It pointstdfs4ds.MCP_SERVER_URLat it (Community edition, streamable HTTP), so the agent's MCP skills (DATA_QUERY, ALGORITHM) work.stop_mcp_server()andmcp_server_status()go with it.- The password is never on a command line: the database URI goes through the server's environment, with
the user name and password percent-encoded (the server hands them to SQLAlchemy, which decodes them, so
@ : / #in a password are safe). - Checked before starting: the logon is tried with the raw driver, and a refused logon starts nothing. A port already used by another program is refused rather than shared.
- Not a dependency. The server runs in its own process: install it in the kernel's environment
(
%pip install teradata-mcp-server) or in its own (pipx install teradata-mcp-server). - Limit: a token logon (
TDFS4DS_TD_LOGDATA) cannot drive it, because the server dropsLOGDATAfrom its own connection. Use TD2 or LDAP. launch_app(mcp=True)starts that server with the app and gives its address to the app, through the new pass-through variableTDFS4DS_SERVE_MCP_URL. The app applies it after loading its--envfile, so anMCP_SERVER_URLin that file cannot hide it. If the server fails to start, the app starts anyway and the failure is in the notes.sagemaker_lifecycle_script(mcp=True)does the same at instance start.- The admin app on SageMaker Studio.
- Wrong Jupyter server:
launch_app()took the newest Jupyter runtime file. On Studio that was another service's (http://localhost:3001/mcp), so the app was started with the prefix/proxy/absolute/8000while Studio forwards/jupyterlab/default/proxy/absolute/8000/, and every page answered 404. The launcher now prefers the server that is an ancestor of the kernel, then one whose URL path is its base path. On Studio it falls back to/jupyterlab/default/. root_path=sets the prefix explicitly.- Proxy check: a 404 written by the app itself is now reported as a wrong prefix, not as a missing
jupyter-server-proxy. - No invented address: the "Full address" line (a
*.notebook.*host) is no longer printed on Studio, whose address cannot be worked out from inside the space.
[0.3.3.6] - 2026-10-14¶
Plots of source data: historian tables, periods, a feature's period as the window¶
tdfs4ds.plot_historian(table, tags, id_values=)draws tags of a historian (deep) table as time series.- Reading the table: its documented layout gives the columns: the tag is filtered, the payload is the value, the timestamp is the axis, and there is one line per id.
- Panels: one panel per tag.
- Matching: tags and ids are matched without regard to case. An unknown tag is refused with the list of tags; an unknown id is named in the notes.
- Also new:
historian_panels,historian_tags,historian_layout. - Periods. A PERIOD column, or two date/timestamp columns named as bounds (
X_START/X_END,BEGIN_X/END_X,ValidStart/ValidEnd), is a period. - Values over a period are drawn as steps. A historical dataset is now drawn this way by default.
kind='periods'draws the periods themselves: 1 while a period holds, 0 outside. The 1-second ramps sit inside the period, so adjacent periods meet cleanly.- Before: PERIOD columns were dropped.
feature_window(feature, entity_value, as_of=)reads the period a feature stores for one entity value, to use as the time window of a series.- It takes a PERIOD feature, or either bound of a
..._start/..._endpair. feature_periods_panel(feature, entity_value)draws every distinct period a feature has held over its history.- Its
NONSEQUENCED VALIDTIMEqualifier stays inside the derived table; over one it is Error 9330. - As an
extra_panelsofplot_historian, it spans the series' time range. - The chat plots documented source objects. A table or view from the business dictionary or the lineage graph, or a tag of a historian table, can be named:
- "plot the close of NVDA and MSFT" (CLOSE is a tag, NVDA / MSFT are ids);
- "plot NVDA close during its last stress episode" (window from
last_stress_start/last_stress_end); - "plot NVDA close with the periods of last_stress_start" (a second panel of the episodes);
- "plot the transactions table".
- Never targets: feature-store internals. A lineage-only name is checked in DBC.
- Answers: they say the data is source data, not features. The LLM spec can target a source object, and every name is checked.
- An incomplete request is completed by asking. "Plot all the stocks during the stress period" names no table, tag or feature.
- The table is found from the documented descriptions ("daily prices of five US stocks").
- What the agent asks: which measure (the table's tags), and which period (the pairs of date features sharing a word with the question, each with the dates it stores).
- The reply completes the request, which the agent keeps between turns: "the close, period 1, around it" (a number names an offered period).
- Nothing is guessed: with no tag named, the agent asks instead of drawing every tag; "all tags" draws them all.
- "around" widens a stored period by its length on each side, at least 7 days.
- Periods superimposed in time.
tdfs4ds.plot_aligned_periods(table, tags, period_feature, id_values=, margin_days=, rebase=True)draws one curve per period that a feature has held (every stress episode). - Axes: x is the number of days from the period's start, so periods months apart overlay. The values are an
index, 100 at the start (
rebase=Falsekeeps them). - Panels: one panel per stock when several are drawn.
- In the chat: "superimpose them", "each episode", "align", "same start", "day 0" do this when a period feature is in play. "raw values" keeps the prices.
- Without periods: "base 100" / "rebase" indexes the series of one window (
rebase=Trueonplot_historian). - Query plans of these charts (read in EXPLAIN).
- Literal bounds per period. The periods are read first (one small query), and the series query has one
UNION ALLbranch per period with literalTIMESTAMPbounds. A PTI historian then reads a time range per branch, where a join to the periods would scan it. - Plain comparisons. Tag and ids are compared without
TRIM. - No NULL test on an expression of x. Staging no longer tests
x IS NOT NULLwhen x cannot be NULL (newx_known). The test on the day-offset expression was pushed into every branch, and turned the time-range read back into an all-rows scan (measured). - An invalid literal removed.
INTERVAL '7' DAY(4)is Error 3706, so it is no longer emitted. - A period stored identically for every entity value (per-stock copies of one episode) is one window.
- Chart answers are a bullet list (they were one paragraph in the app).
- No point limit by default.
PLOT_MAX_POINTSis nowNone: TD_PLOT draws large requests, and its whole error comes back when it fails. A number still refuses requests above it. - Default value of a plot: an identifier column (
CustomerID,customer_id,*_KEY) is no longer chosen as the value. - Demo notebook T2 ends with the NVDA close and its stress episodes, drawn in the database.
- Live-checked on DB_LAB: the six chat questions above, French included, and every library call. Guarded by
tests/test_plot_sources.py.
[0.3.3.5] - 2026-10-14¶
Four gaps found by running the agent evaluation and the serving benchmark from the admin app on the demo lab store.
Fixed¶
- "Where does that object come from?" after a question on a column. The agent found the column's table but
positioned it in the lineage graph by its bare name, looked up under the store's database. A source table lives in
another database (
DB_SOURCE.MARKET_PRICES), so lineage answered "could not be located". skill_lineagenow positions the qualified owner.- A bare name outside the store's database is accepted when exactly one lineage node carries it.
- A DEFINITION answer about a non-feature column records its table, so the follow-up keeps it.
- In the evaluation run, both such turns had scored 1 out of 5. Live, the follow-up now lists the 4 objects that depend on MARKET_PRICES.
- "What features are available for CustomerID?" asked which data domain instead of answering. An entity named in the question, without a data domain, now scopes the list:
- One data domain has the entity: its features in that domain.
- Several have it: one section per data domain.
- The conversation's data domain has it: that domain only.
- Benchmark report inside the app: the section titles were navy on the report's dark panels, and unreadable. The report now gives them its own text colour.
Evaluation¶
- Columns of a historian table. A name documented on a deep (historian) table is a tag, not a column, and the agent says so. The judge did not know the table's layout, and scored that framing as invented.
- The evaluation now reads the table's layout, and the column facts state that the name is a tag, with the layout sentence and the layout's column names.
- The judge is told that this framing is documented.
[0.3.3.4] - 2026-10-13¶
The admin app from a notebook (SageMaker, JupyterHub, AI Studio)¶
tdfs4ds.launch_app(env_file=None, port=8000)startspython -m tdfs4ds.servein a separate process and shows a link ("Open the tdfs4ds app").- In a notebook, the link goes through Jupyter's own proxy,
<jupyter base>/proxy/absolute/<port>/. The base is read from the Jupyter server's runtime file, and the app's path prefix is set to match. - Outside Jupyter, the link is
http://localhost:<port>/. - What it starts and skips: it skips the standalone Gradio chat and the docs server. It passes
--envonly when the file exists, and says so otherwise (the app then starts disconnected). - Waiting and checking: it waits for
/health, reuses an app already running on the port, and shows the log tail when the app stops at start. It asks Jupyter's proxy whether it reaches the app, with an install hint when it does not. tdfs4ds.app_status()andtdfs4ds.stop_app()use a state file (~/.tdfs4ds/app_<port>.json).stop_appnever stops an app it did not start.tdfs4ds.sagemaker_lifecycle_script(env_file=, version=None, start_app=True)returns the SageMaker "Start notebook" script. It installs the latest tdfs4ds (or a fixed version) in thepython3conda environment and starts the app behind the proxy, all in the background.- Checked on this machine with SageMaker's layout: a Jupyter server with
jupyter-server-proxyin its own environment, separate from the kernel's. - Page, stylesheet, GET and POST requests (Connect, create preview) and the chat stream all work through the proxy.
- Not run on a SageMaker instance.
- Panels: the feature catalog and dataset builder say "No feature store is selected" instead of showing
Error 3707 (
FROM .<table>) when no store is chosen.
[0.3.3.3] - 2026-10-13¶
The chat keeps the conversation's entity and topic¶
Seen live: "build a dataset for customer id" was built on Category, "what are these datasets?" listed all 13 datasets instead of the 5 on CustomerID just mentioned, and "I meant for CustomerID?" was answered as a definition.
- Choosing the entity of a dataset build:
- Order: the entity named in the question first, then the entity of a named feature, then the conversation's entity, then the only entity there is. Otherwise the agent asks, with a numbered list. It used to take the entity with the most features without saying so.
- Spellings: "customer id", "customer_id", "customerid" and "CustomerID" all name
CustomerID. The exact entity wins over a wider key (Category,CustomerID). - Replies: the answer to "which entity?" stays in the flow even when it ends with "?". Naming another entity instead of "ok" restarts the proposal on that entity.
- Fixed text: the feature proposal and the entity question are written by code. The LLM rendering announced the user's entity over another entity's features and invented "additional features".
- Scoped dataset lists: "datasets for CustomerID" and "what are these datasets?" (the conversation's entity) list
that entity's datasets only (
skill_list_datasets(entity=, data_domain=)), with "N of M" in the header. - Narrowing follow-ups: a short follow-up that only narrows ("I meant for CustomerID?", "what about Category", "pour CustomerID") redoes the previous request (dataset list, build, feature listing, or the same question on a feature) for that entity, without a classifier call. A longer question is classified on its own.
- Better logging:
_dataset_kind_tagslogs a failed read of the build table as a warning. Every dataset showed "time unknown" with no trace of why.agent/graph.pydefines theloggerits except branches used. It was never defined, so reaching one raised aNameError.
Dataset builder: existing datasets and samples¶
- An Existing datasets card lists the registered datasets (by default those on the selected entity), with their kind, feature count and entity.
- Sample 20 rows works on any registered dataset, and on the dataset being defined before it is created:
the generated query runs with
SAMPLE 20and nothing is created. Only registered datasets can be sampled by name. Routes:GET /ui/dsbuild/datasets,GET|POST /ui/dsbuild/sample.
[0.3.3.2] - 2026-10-13¶
Dataset builder panel (admin app, Explore)¶
- The flow: pick an entity (grouped by data domain, with its key columns and existing datasets), then its features, then a view name, then the kind of dataset; then create it.
- Feature definitions on hover: the description, the producing process, the last run and the source of the description. A feature computed by several processes has a list to pick one (by default, the one that ran last).
- Kinds:
- snapshot: current, as of a date, or following a TimeManager;
- historical: start / end / duration / delay, or the full history;
- join: inner or full outer;
- documentation: optional, and only when an instruct model is configured.
- Name check: Create dataset stays disabled while the name is taken, either by a registered dataset or by any table or view in the view database. The server checks again before creating, so nothing is ever replaced. Show SQL previews the view. A selection that matches an existing dataset's features is flagged.
- One creation path: creation goes through the chat's
skill_build_dataset, which gainsjoin_type,document, the historydelayand the snapshot kindtime_manager. Existing calls are unchanged. - Routes:
/ui/dsbuild/entities,/features,/nameand/create(with?preview=truefor the SQL only).
Feature catalog: the SQL chain down to the sources¶
- The SQL of a process is now its chain. It starts with the process view (the result), then every view it reads, each before the views it reads, down to the source tables. Each step names the step(s) that read it.
- Where it stops: at source tables, dataset views (feature store), Time/FilterManager views (including a soft
clone over another manager's
_HIDDENtable) andFS_T_*/FS_V_*objects. These are listed as inputs. - Where it comes from: the persisted lineage graph, read once per export, with one SHOW VIEW per distinct view.
A process absent from the graph is walked from its SQL, with a hint to run
rebuild_lineage_graph(). - Phantom nodes: lineage nodes that are not database objects (SQL tokens such as
APPLY,1) are left out and named. - Colours. Each step has its own colour, and each input kind has its own colour.
- In the app, references are tinted and clickable (they open the step).
- In the HTML export, they are links.
- In Excel, they are bold rich text in the target's colour, the line links to the step, the step header rows
are filled, and there is a legend on About.
openpyxl>=3.1is now required. - New functions:
catalog_export.process_sql_chain(pure),read_sql_chain,chain_text,reference_spans.highlight_sql_html(refs=)gains the reference marks. - Panel fix: long values in the side panel's detail tables now wrap instead of scrolling the panel sideways.
[0.3.3.1] - 2026-10-13¶
Feature catalog panel and export¶
- Horizontal scroll bar with the side panel open (reported issue). The table used to shrink into the space left by the side panel, and when it did overflow, its scroll bar was under the last row, below the window. It now keeps a minimum width (1,100 px) and scrolls inside a box sized to end above the bottom of the window (refitted when the side panel opens or closes, and on resize), with a sticky header.
- Filter by process. A Processes multi-select (the processes of the selected domains, with their feature count) and a Show only this process button in the side panel. The exports follow the filter.
export_feature_catalog(..., processes=)andfeature_catalog_documented(..., processes=): a PROCESS_ID or view name (qualified or not, any case), or a list. An unknown one raises with the existing views. The About sheet / HTML header names the processes kept and counts what is kept.None(the default) gives the same output as before.
[0.3.3.0] - 2026-10-12¶
Charts drawn in the database by TD_PLOT, from Python and from the chat: line, step history, bar, histogram,
scatter, panels and correlation heatmap, with entity selection, zoom and download. Feature registration no
longer leaves a tdfs_tmp_* table behind.
In-database plotting (tdfs4ds.plotting)¶
plot_feature(name, kind='distribution'|'history', entity=, data_domain=, entity_values=, process_id=, as_of=),plot_dataset(view, y, x=, series=, kind=, agg=),plot_in_database(source, x, y, ...)andshow_plot(result), exported ontdfs4ds. The chart is drawn by the UAF functionTD_PLOT; only the image (tens of kB) leaves the database, whatever the number of points.- How: count the rows (refused above
PLOT_MAX_POINTS, 1,000,000), copy them to a volatile table (x/y/id, no CREATE right needed), keep the firstPLOT_MAX_SERIES(12) series and say so, runTD_PLOT, drop the tables. Requests of at mostPLOT_INTERACTIVE_MAX_POINTS(2,000) points also get an interactive Plotly version; above that the rows are never read back. - Feature plots read the storage table (
FS_T_*,FEATURE_ID+FEATURE_VERSION): a histogram at the valid time, or the history (a daily feature on its date column, otherwise onValidStart). A name used by several entities or domains is refused with the candidates; with several producing processes the most recently written one is plotted, and the result says so. - Dataset plots take their defaults from the dataset catalog (y = a feature, series = the entity columns
that are not dates, x = the date column or
ValidStart). - Found live on Vantage 20, built into the SQL: a volatile table is a valid
TABLE_NAME; dates print only withXFORMAT('YYYY-MM-DD')(%Y-%m-%dis Error 9153, no format prints 1, 2, 3); a statement-level temporal qualifier over derived tables only is Error 9330, so a temporal histogram first copies the raw values; one series per category draws unreadable bars, so a category bar chart is one series over positions 1..k with the names in the axis label. 1.5-2 s per chart end to end;TD_PLOTdrew 200,000 points in 0.5 s. PLOT_PLOTLYJS = 'cdn'(default): the interactive version loads plotly.js from its CDN (inlined, every chart would weigh 4.8 MB in the chat's conversation memory);'inline'for offline use. TheTD_PLOTimage does not depend on it.
Panels, series choice, step histories¶
- Several panels in one image (one
TD_PLOTcall, oneSERIES_SPECper panel,LAYOUT(columns, rows),CELL(column, row); line and bar panels mix - verified live):plot_features([...]),plot_dataset(y=[...]),plot_panels([panel(...)], layout=). At mostPLOT_MAX_PANELS(6); a panel with nothing to draw is left out and named, the others are drawn. - Which series:
max_series=andseries_order='largest'(default: by latest value),'smallest','alphabetical','most_points'. The default was alphabetical, which drew an arbitrary 12 of 1,958 customers. - Which entities:
entity_values=[...](a value matches the whole key or one of its columns);feature_entity_values/dataset_entity_valuessay which words of a question are real values. - A history on valid time is a step: each value from
ValidStarttoValidEnd(to now when open). It used to join the starts only, so a value that never changed after March looked as if it stopped in March. Each end point is one second early: with the end of one period and the start of the next at the same instant the line went back and forth (a sawtooth, seen live). - The chat frame follows the image height (two stacked panels were cut off).
Correlation, zoom, download¶
plot_correlation(view, columns=None): all Pearson correlations in ONE pass (CORR), the k x k matrix built from that row, drawn byTD_PLOTas ameshheatmap over aMATRIX_SPEC(colour bar -1..1, verified live);r['matrix']holds the values. In chat: "correlation matrix" / "heatmap" / "matrice de corrélation".- Zoom:
xlim=/ylim=on every plot function. A time window filters the rows in SQL (TD_PLOTrefusesXRANGEon a time axis, Error 3706, measured); a numeric one also setsXRANGE; step histories are cut at the edges;YYYY-MMas an upper bound includes the month. In chat: "between A and B", "from A to B", and a follow-up "zoom from A to B" redraws the previous chart (remembered as_last_plot). Window dates no longer become the conversation's valid time (they did, and the dataset catalog was then read before the dataset existed - seen live). - Download and actual size: each chart page has Download PNG, Download SVG (an SVG copy for requests up to
PLOT_SVG_MAX_POINTS, 20,000) and Actual size; the chat frames are now sandboxedallow-scripts allow-downloads(still no same-origin).save_plot(r, path)writes .png / .svg / .html. - TD_PLOT limits measured and handled: text arguments over 127 characters (a 7-column correlation label) and
images under 400 x 400 pixels are refused with a bare Error 3706 - texts are cut, sizes raised; a date and a
time cannot share one
XFORMAT(Error 9153), so the format follows the span (HH:MIwithin a day with the date in the label,YYYY-MM-DD,YYYY-MMover ~13 months) - a history written in one day printed the same date on every tick. - Tests no longer reach the configured instruct model (the repo
.envsets one; a test went through the LLM fallback and was flaky). 92 offline plotting tests.
PLOT skill in the chat¶
- New
ca-plotskill (intentPLOT): "plot the distribution of X", "show X over time for the Y entity", "plot A by CATEGORY from DS", "plot A against B in DS", "trace l'évolution de X". The request is read by code (named dataset or feature, its named columns, plot words) and every column is checked against the object; the answer is fixed EN/FR text. An ambiguous feature name lists its entities and shows how to ask. - A question with a plot word and no lineage word is routed to PLOT by code, whatever the classifier says.
- MCP
plot_*tools are no longer bound (mcp_connection.without_client_side_plot_tools): they read the rows out of the database to draw them client-side. - Charts carry a
height; both chats size their frame with it. - "plot A and B over time" draws panels; "top 5", "the 3 smallest", "les 5 plus grands" choose the series; entity values in the question restrict the chart.
- A request that names nothing is read by the instruct model against the store's datasets and features (JSON with a schema); invented names are dropped, and the answer says how the request was understood. No model: the agent asks for a name.
- A name shared by several data domains with the same entity asks for the data domain (it asked for the entity, which the question already gave).
Leftover tdfs_tmp_* tables of feature registration¶
GetAlreadyExistingFeatureNamesdropped its temporary table withtdml.execute, which teradataml does not have; theAttributeErrorwas swallowed, so every feature registration left onetdfs_tmp_<12 hex>table in the store's database (250 in the demo store). Fixed (tdml.execute_sql, a failure logged at DEBUG); live-checked: a registration no longer leaves one.-
tdfs4ds.cleanup_temp_tables(dry_run=True, database=None, min_age_hours=1)lists those tables (exact name pattern only, older than an hour) and drops them withdry_run=False; a refused DROP is reported per table. Guarded bytests/test_temp_table_cleanup.py. -
Guarded by
tests/test_td_plot.py(67 offline tests). Live-verified on DB_LAB through the agent: 7 plot questions (dataset line, bar by category, scatter, feature distribution with two producers, daily feature history in French, ambiguous name, history for a named entity) and a lineage question still routed to LINEAGE.
[0.3.2.5] - 2026-10-12¶
The chat creates, registers and documents a dataset, asking its kind and parameters; dataset lookups match features by store; the feature catalog panel handles a feature computed by several processes and highlights its computation in the process SQL.
Datasets from the chat¶
Seen live: "where can I find X?" counted features of an entity name across domains; "what are the available datasets?" was swallowed by a waiting dataset flow and answered with a feature list; that flow never built anything ("ask your data team"); and the MCP sample query used bare dataset names (Error 3807).
- The agent creates, registers and documents the dataset, only when the requested one does not exist. The database decides permissions: a refused CREATE VIEW is shown with the database's own message.
- It asks the kind and its parameters: a current snapshot, a snapshot at a date (
build_dataset(as_of=)), or a history over a window (build_historical_dataset: "last 90 days", "les 6 derniers mois", "from … to …", "all"). A missing parameter is asked again by name. - Timestamps: ISO dates and times,
T,Z/UTC/GMT,+02:00/+0200, and textual dates in English and French. No offset means UTC, and the answers say "UTC". Slash dates (03/04/2026) are refused as ambiguous, and an impossible date is refused. A date typed as a parameter never becomes the conversation's valid time. - Name check: taken in the dataset catalog, or any object of that name in the view database (
DBC.TablesV); an unreadable DBC stops before building. - The result: registration is read back from the catalog, and the documentation status is reported. The vector
index is not rebuilt (it took minutes live); the answer says the dataset reaches semantic search at the next
refresh.
build_datasetfailures that are logged rather than raised are captured and reported. - Lookup by identity: datasets are matched on (FEATURE_DATABASE, FEATURE_ID), since a FEATURE_ID is unique only within one store. A dataset reading another store's features no longer matches a local feature with the same id (found live; the catalog export had the same defect, also fixed). Found datasets show their entity and domain; a failed read is reported as such, never as "no dataset".
- Dataset inventory: "what are the available datasets?" (EN/FR) lists every dataset with its entities and kind, the kinds read in one query (5 s live, was 55 s).
- Leaving the flow: a new question while a dataset step waits is answered and the flow dropped; "create it" inside the lookup flow moves to the build flow.
- MCP: the sub-agent gets qualified dataset names and a dataset → feature map, and a tool error (without driver frames) goes back to the model, which corrects and retries once instead of the turn failing.
describe_db_erroralso drops SQLAlchemy's "Background on this error" link.- Guarded by
tests/test_agent_dataset_build.py(52 offline tests). Live-verified on DB_LAB (as-of and history datasets built, registered and documented, then removed); the MCP path was not verified live.
Feature catalog panel¶
- A feature computed by several processes has one row per process, each marked "1 of N producing processes"; the side panel names the others, and each opens its own SQL.
- The selected feature in its process:
- the side panel shows the process's data domain and entity;
- a "Computed as" block gives the feature's expression at each level (outer SELECT item, then the derived tables / CTEs it reads) and the grain;
- in the SQL the computation is underlined and every mention of the name is marked; a link scrolls to the computation. The name is also marked in the process's feature list and business logic.
- This comes from
sql_context.computation_context, whose levels now carry their characterspan, and fromcatalog_export.highlight_sql_html(pure, escaped). - One catalog read at a time. A page load and a filter change building at once (two threads through teradataml's shared pool) left the panel loading. The panel's reads are now serialised, and the page ignores a response older than the last request.
[0.3.2.4] - 2026-10-12¶
Finding the databases that hold a feature store, connecting to a store you may only read, the classic chat matching the app chat, suggested questions read from the connected store, updated documentation skills, and the feature catalog exported with its processes and documentation.
Feature catalog export (XLSX, HTML) and panel¶
tdfs4ds.export_feature_catalog(path=None, *, fmt=None, as_of=None, data_domains=None, with_sql=True)writes each feature joined with its producing process and its documentation:- XLSX sheets: About (store, valid time, domains, counts, links to the sheets), Features (one row per
feature × producing process), Processes and SQL.
- Features and Processes are Excel tables. Features are grouped by domain, entity and process, with the first three columns frozen.
- A process name links to its SQL. The SQL sheet holds each process's SQL one line per row, with links back to its first feature and to its process.
- HTML: one self-contained page, the features table linking to each process and its SQL.
- DataFrame:
tdfs4ds.feature_catalog_documented(...)returns the Features table. - New module
tdfs4ds/catalog_export.py;openpyxlis now a dependency. - Columns: feature (domain, entity, name, id, type, storage table), its description with provenance (manual / imported / propagated / generated, editor, date), the process (id, view, type, FilterManager, TimeManager, business logic, entity description), the datasets that use it, the last run and its status, and the EXPLAIN scores.
- Missing values: a value that is not available reads
unknown(e.g. no EXPLAIN score without an LLM); a feature in no dataset readsnone, a process without a runnever run. - Link to the process: a feature is linked to its processes by triplet through
catalog_counts' producers, never by name within a domain. A feature with several producers gives one row per process, one with none ano producing processrow. - Filters:
data_domains= all, one or a list (case-insensitive; an unknown name raises with the existing ones).as_ofreads the catalogs, business dictionary, process documentation and provenance at that valid time (base tables,VALIDTIME AS OF), and the last run up to that time. - The SQL is today's text, since view definitions are not versioned; an export at a past date says so.
- Long SQL: one line per row in the SQL sheet. A single line longer than an Excel cell (32,767 characters) continues on the next rows, marked continued, never cut.
- Reads: batched (one query per documentation table, 500 names per IN-list), plus one
SHOW VIEWper process view. Live on the demo store (67 features, 13 processes): about 6 s. - Admin app, Explore › Feature catalog:
- 20 features per page, filtered by data domains (multi-select), date and text;
- a row opens a resizable side panel with the feature, its process and the process SQL, read on demand;
- Export HTML / Export XLSX download the file for the current filters;
- visible in the user view too.
Chat¶
- The classic (Gradio) chat now matches the app chat: the app's colours (light and dark), the whole page width, the conversation in a centred column with no box around it, the suggestions as pills under the heading, a single-pill composer, a full-width answer bubble and the plain orange avatar. 0.3.2.3 had the same parts but Gradio's own layout (a narrow page, a boxed conversation, suggestion tiles at the bottom).
- Suggested questions come from the connected store and follow a switch. They used to be read from the
agent's per-turn cache, which outside a turn could still hold the previous store, and the app fetched them once.
chat_suggestions()(inagent/chatbot.py, shared by both chats) now runs three small reads (current processes, last completed run per process, documented columns of their views, about 0.7 s) and names a documented feature whose process ran most recently, with its data domain; "Is X up to date?" is offered only when that process has run, "Which data domains exist?" only when there are several. The app reads them again each time the chat is shown and after a store switch in Parameters or Connect; the Gradio chat at each page load.
Finding feature stores, and connecting to one you may only read¶
tdfs4ds.find_feature_stores(complete_only=False, details=True, databases=None)lists the databases that hold a feature store, as a DataFrame:DATABASE_NAME,VIEW_DATABASE(found from the view's text for a store set up withdatabase_view),COMPLETE/MISSING(the tablesconnect()needs), and withdetailsREADABLE,N_PROCESSES,N_DATA_DOMAINS,ERROR(the whole refusal). One read ofDBC.TablesV, so only what the session may see, plus one count per store (about 1 s for four stores, live). New moduletdfs4ds/store_discovery.py.- Parameters panel: a list instead of free text. The stores are looked for once at app startup (and after a
login in Connect); Refresh list searches again. Each entry shows its process and domain counts; a store
connect()would refuse (incomplete, or its catalog unreadable) is shown but cannot be chosen, with the reason as its tooltip. Free text comes back only when the search itself failed. connect()works on a store the user may only read. It refreshes the business-dictionary views withREPLACE VIEW, which needs the DROP right; a refusal raised, so a lab account could not connect to the production store it may read (seen live on DB_PROD). The existing views are now kept with a warning (is_access_denied_error, matched on the phrase "does not have ... access to", never on a code). The refresh of objects that already exist (COMMENT ON,REPLACE VIEWafter an idempotent CREATE) no longer logs ERROR for the same refusal.connect()reads DBC with raw SQL.DataFrame.from_queryoverDBC.TablesV/DBC.ColumnsVcreates a view, which needs WITH GRANT OPTION on DBC (Error 3523 for that account), and the fallback then listed the whole database.- A refused store switch in the app leaves the session where it was.
connect()setsSCHEMAbefore it can fail, so a failed switch used to leave the app half on the new store; the previous settings are now restored. The message no longer carries the driver's Go stack frames, and a failed index rebuild after a good switch is reported as such instead of as a failed connection. - Skills.
fs-documentsays which way to document (at registration, one process, a dataset, a plain view, the whole store withrebuild_documentation, after editing views, by hand) and which of them refresh the vector index; itsdocument_view_incrementalexample had the wrong signature (it takes the database and the view).fs-vector-storedescribes the Teradata refresh as it is since 0.3.1.169 (in place, then destroy + recreate, neveradd_datasets) instead of an unsolved limitation, with a table of what refreshes the index.fs-setupshowsfind_feature_stores().
[0.3.2.3] - 2026-10-12¶
Chat fixes, documentation of deep (historian) tables, and the time-series demo track.
Chat fixes¶
- Table headers readable in the light theme. A model writes bold headers (
| **date** |), and the chat's bold text is navy, the colour of the header background in the light theme, so the headers disappeared (dark theme was fine). Header text is now white whatever it holds, in the admin app's chat and in the other panels that render model Markdown. - Light theme background.
--td-softreferred to itself (var(--td-soft)), which is invalid: zebra rows, quotes and the edit log had no background in the light theme. - Resizable context graph. Drag the left edge of the panel (it keeps the chat at least 420px wide); the width is remembered per browser, a double-click restores 440px, and the graph refits after a resize.
- Copy chat. A header button copies the whole conversation as Markdown (questions, answers and errors). Copy
falls back to a hidden text area where the browser clipboard is unavailable (an
http://page behind a proxy), which also fixes the per-answer Copy button there. - The classic (Gradio) chat looks like the app chat, at
/chatbot, on the standalone port and in notebooks (launch_chatbot,launch_chatbot_with_index): the same header buttons (Context graph, Copy chat, Clear, New chat), bubbles, avatar, suggestions in the empty conversation, pill composer with a Send button, and the context graph in a side column. Its style is now actually applied: Gradio 6 ignorescss/themeongr.Blocks, so they are passed tolaunch()andmount_gradio_app()(chat_style_kwargs). Gradio's own copy-all button is on. It follows the system light or dark theme. Behaviour (thread, MCP toggle, trace) is unchanged.
Deep (historian) tables¶
A historian stores one row per id, timestamp and tag (sensor, signal), the value in a payload column. Its tags can
now be documented as if they were columns of the pivoted table (tdfs4ds/genai/deep_tables.py). No new table, no
ALTER; a table without a layout behaves exactly as before everywhere.
set_deep_table_documentation(database, table, *, id_columns, tag_column, payload_column, timestamp_column, timestamp_timezone='UTC', description, columns, tags, ...). The layout is checked against the table's columns and stored as the reserved business-dictionary sectionDEEP_LAYOUT(JSON), theFEATURE_GROUPSprecedent; it is required once and read back afterwards. Each tag is aFS_BUSINESS_DICTIONARY_COLUMNSrow of the table, with the usual provenance (a tag list loaded withsource='IMPORTED', a correction MANUAL), log, index and protection from LLM runs. Tags come from a dict or a DataFrame with an optional unit column. A tag that is also a physical column, longer than 128 characters or not Latin is refused with the reason, nothing written.deep_table_tags(database, table, since=, until=, documented=)lists the tags present in the table (oneGROUP BY, bounded on the timestamp), marked documented or not. It reads the table, so it is opt-in.- The LLM receives only the tags a process's SQL names. When documenting a process or an intermediate view that
reads a deep table, its context holds the layout, the physical columns and the tags whose value appears as a
literal in the SQL, whatever the shape (CASE WHEN, IN, PIVOT, join condition). When none is named, at most
DEEP_CONTEXT_MAX_TAGS(40) are listed. A tag compared with the tag column but undocumented is logged. The prompt for a wide table is byte-identical (tested). - A tag edit's stale list is the processes whose SQL (or an upstream view's) names the tag; with
regenerate_dependents=Trueonly they are re-documented. - Agent: a DEFINITION of a tag starts with a fixed sentence (EN/FR) saying it is a tag of the historian, its readings in the payload column, by the id columns and the timestamp.
- Admin app: Objects > Edit on a deep table shows the layout, the columns and a Tags group (filter, pages of 200, Add a tag); each tag is corrected, protected or released on its own, with the usual preview and confirmation. The coverage panel says "Deep table · N tags documented".
- Reserved sections are excluded by every reader through one list (
RESERVED_SECTION_NAMES): the BD sections view behind the Teradata vector store, the Chroma index, the coverage panel. On a store without a layout row the results are unchanged. An older client sharing a store with a deep table shows the layout JSON as a section and gives its LLM every tag. - Fixed on the way: a business-dictionary
OBJECT_TYPEreads back padded ('T '), so source tables were shown to the LLM as "Object" instead of "Table", and legacy source-table documentation was labelled "generated or unknown (before 0.3.2.1)" instead of "imported (before 0.3.2.1)". It is now stripped at the read.
Demos: time-series track (--timeseries)¶
A risk desk watches contagion: when NVDA goes through a stress episode, how did the other holdings behave over the
same sessions? Five stocks (2013-2018) from the Kaggle dataset "S&P 500 stock data" (CC0), in
demos/data/stock_prices_daily.csv with its source in demos/data/README.md, loaded by Setup.ipynb into a
deep PTI table DB_SOURCE.MARKET_PRICES (historian layout: PRIMARY TIME INDEX (TIMESTAMP(0), ..., DAYS(1),
COLUMNS(TICKER, TAG)), UTC timecode). Data domain Market Stress Episodes.
- T1 reads the table and documents it as a deep table (tags imported, one corrected, the undocumented one found).
- T2: a TimeManager starting in the past; process 1 finds episodes with
DELTA_T(CTE,LAG, start/end flags, as a historian session view); process 2 reads the stored episode from a dataset attached to the TimeManager and measures every stock over it (min, max, average, std, drawdown, return from the close before the shock, relative to the reference);roll_outover 14 trading days; values equal a pandas reference at sampled dates. - T3 (instruct model): the LLM's context holds only
CLOSEof the five tags; the agent frames a tag. - Diagrams and plots. Three generated diagrams (
demos/diagrams/make_diagrams.py):historian(deep rows read as a pivoted table),delta_t(NVDA's real August 2017 returns, the start/end events and the restart rule),stress_pipeline(the two processes, the feature store and the TimeManager). Three matplotlib plots, drawn from data read back from the database (dc.plot_market_signals,dc.plot_stress_episodes,dc.plot_contagion): the five stocks over 2017 with NVDA's event days, the stored episodes on NVDA's closes, and every stock over the last episode with its return relative to NVDA. - Learned on the database, recorded in the notebooks:
DELTA_T((WHERE ...), (WHERE ...))is the syntax; a later start before an end restarts the episode; underGROUP BY TIMEa timecode predicate must be a literal; a PTI cannot also be row-partitioned; a PTI reads a time range only with literal bounds (run-time bounds give a scan, a period join gives a product join); DELTA_T'sTIMESTAMP(6) WITH TIME ZONEbounds refuse aCASTtoTIMESTAMP(0)(Error 7454).
[0.3.2.2] - 2026-10-11¶
Release of the 0.3.2.1 work below (0.3.2.1 was not published): documentation written by hand, the provenance and documentation-log tables, LLM runs that keep human text, the admin app's edit with preview, feature descriptions filed under the right entity, and broken Vector Store collections recreated. No other change.
[0.3.2.1] - 2026-10-11¶
Writing documentation by hand¶
Features, processes, datasets and source tables can be documented by hand, from Python or from the admin app, with no LLM. With an LLM the LLM writes first and a person corrects; the correction survives the next LLM run. Without an LLM this is the documentation. An LLM configured later documents only what nobody wrote.
- API (
tdfs4ds/genai/manual_documentation.py, exported at top level): set_feature_documentation,set_process_documentation,set_dataset_documentation,set_object_documentation;propagate_documentation: dataset columns filled from the feature documentation, no LLM;get_documentation,mark_documentation_manual,release_manual_documentation,documentation_log.- Where a feature edit is written. A feature's description is written in both places it is read from: the process documentation, which datasets inherit, and the business dictionary on the process view, which the agent, the index and the app read. It is copied to every dataset exposing the feature.
- What each call returns. A report of what was written and copied, the human copies left alone, the
LLM-written documentation that the edit makes stale (
regenerate_dependents=Trueregenerates it when a model is configured), and the vector index refresh.dry_run=Truereturns that report without writing. - Feature identity. A feature is resolved as its (data domain, entity, name) triplet. A shared name raises with
the candidates; when
data_domainis omitted,tdfs4ds.DATA_DOMAINdecides. - Who wrote what, in a new optional VALIDTIME table
FS_DOCUMENTATION_PROVENANCE: - values:
IMPORTED(a dictionary load: the right tag for source data),MANUAL(a person's edit),PROPAGATED(a copy). No row means the part was written by the LLM, or before 0.3.2.1; - author: the database
USERand a declared editor name. - What was done, in a new optional
FS_DOCUMENTATION_LOG. One row per action (manual edit, import, propagation, LLM documentation, protect, release, index refresh) records: - its status (a failure keeps its whole error) and where it came from;
- the
RUN_ID/PROCESS_IDthat join it toFS_FOLLOW_UP.
Documentation events are deliberately not written to FS_FOLLOW_UP: every reader of that table treats a row
as a process run, and run() skips a computation that already has a COMPLETED row.
- No ALTER. Both tables are created by setup() / connect() when missing, and the six documentation tables
are untouched. A user who cannot create them still documents; the edits are then neither protected nor logged,
and a warning says so once.
- LLM runs keep human parts.
- Writers: upload_documentation, _store_dataset_doc and _store_intermediate_view_doc leave
IMPORTED / MANUAL parts as they are. A protected feature is not expired by the "features no longer
documented" step.
- Prompt: document_process_incremental gives the human text to the LLM as authoritative context, and lays
it over the LLM output so the change detection compares the final text.
- Overriding: overwrite_manual=True (new keyword) on document_process_incremental,
document_dataset_incremental, rebuild_documentation and cascade_redocumentation lets the LLM replace
them.
- upload_business_dictionary_* gain source= (default 'IMPORTED') and author=. source=None records
nothing, and the LLM pipelines and the migration use it, so their SQL is unchanged. migrate_objects carries the
provenance rows of the objects it promotes.
- Admin app (Documentation › Objects › Edit documentation, admin view):
- one field per part, each with who wrote it;
- a required editor name;
- a preview dialog (what is written, copied, kept, made stale, re-indexed) before Confirm and save;
- options to copy, overwrite, re-index or regenerate with the LLM (disabled with the reason when there is no
model);
- Protect / Release per field, and the object's history.
The Coverage panel says who wrote the documentation, and the Report has a Documentation activity section. - Demos: - 01 loads a data dictionary for the source table; - 02 documents features and a process by hand; - 03 documents a dataset without an LLM; - G1 corrects what the LLM wrote and shows the correction kept.
01–03 were run with and without an instruct model.
Fixed¶
rebuild_documentation(force_update=False)documented only the gaps in name. It re-documented every process that was a graph root or absent from the graph, whether or not it was documented. Documented processes are now skipped, as its docstring said. This matters for a store documented by hand before an LLM is configured.-
The coverage panel's hint named
tdfs4ds.upload_business_dictionary_objects, which was not exported at top level; it is now, and the hint namesset_object_documentation. -
upload_documentationfiled a feature's description under the other entity's FEATURE_ID too. It resolved FEATURE_ID by name and data domain only, so a domain holding one name for two entities got the description under both ids. Read back by name, the extra row could hide a hand correction. The MERGE and the expire step are now restricted to the process's own ids (name, domain and entity, compared order-insensitively); when they cannot be resolved the SQL is unchanged. The expire step then closes the misfiled rows of each process it re-documents. - Existing stores:
tdfs4ds.genai.repair_feature_documentation_entities(dry_run=True)lists those rows, anddry_run=Falsecloses them (history kept). It found 11 on the demo store. - A Teradata Vector Store collection whose own backing object is missing (
Object 'collection_<name>_index' does not exist) was reported as "service unreachable or not enabled … check licensing"._is_broken_collection_errornow classifies it.build_teradata_indexrecreates such a collection, or says that an admin must when the destroy is refused. Seen on the demo store: all three collections had lost their_indextable; they were recreated and search works again.
[0.3.2.0] - 2026-10-11¶
A chat built into the admin app, a user view, and Opik tracing¶
- Native chat panel. The admin app's Chatbot panel used to be the Gradio chat in an iframe. It is now part of
the app (
serve/chat.py,/ui/chat/*, SSE). It has: - streamed answers with a live status line;
- Markdown rendered server-side, with charts inside the answer;
- Copy, and a folded Sources line;
- suggestion chips named after the connected store's own objects;
- Clear vs New chat;
- one conversation per browser.
Turns are answered one at a time, as Gradio's queue did, because a turn sets process-wide state. The Gradio chat
stays at /chatbot and on its own port.
- User / Admin view. A switch in the top bar, remembered per browser. The first view comes from
TDFS4DS_SERVE_DEFAULT_VIEW (admin by default, so nothing changes for an existing deployment).
- The user view shows the conversation and the Chatbot, Report, Lineage and Objects panels.
- The admin view adds, under each answer: intent, skill, domain, timed steps, MCP tool calls, tokens and the
full trace. It also has a context-graph side panel, and every panel.
- The switch is not access control.
- Dark theme for the shell (system preference, or the ◐ button), and a phone layout.
- Opik tracing (tdfs4ds/utils/tracing.py), opt-in:
- Enable it with TDFS4DS_TRACING=opik and pip install tdfs4ds[opik].
- An OpikTracer callback in the LangGraph run config of the consumer agent and the query optimizer gives one
trace per turn, with a span per node and per LLM/tool call, grouped by thread_id.
- Opik's own settings decide where traces go. tdfs4ds never reads OPIK_*.
- The Opik SDK's analytics (stats.comet.com) and Sentry reports are switched off unless
TDFS4DS_OPIK_VENDOR_TELEMETRY=true. Seen live: with its defaults the SDK posted to stats.comet.com on the
first traced turn.
- Tracing never breaks a turn.
- check_configuration has a new Agent tracing row.
- AgentOps packages trace with Opik. config.yaml carries TDFS4DS_TRACING: "opik" unless the session says
'off', and opik in requirements.txt is now used. The entrypoint stays a bare coroutine: the tracer is a
callback, not a decorator.
Fixed¶
- Token counts were always 0.
- The cause:
consumer_agent()/consumer_agent_stream()readinput_tokens/output_tokensfromTokenTracker.totals(), which returnstotal_input/total_output, and re-added the whole session'sby_skillon every turn. - Missing calls: only
_llm_callrecorded usage, so the intent classifier and every structured-output call were never counted. - The fix: a turn now records every model call through a callback, labelled by graph node.
_llm_calltags its own call so it is not counted twice. Measured on the demo store: 3.8k tokens for a turn that showed 0. - A list that opens indented ("Total\n - a\n - b", the shape of the data-domain answer) rendered as a code block in the admin panels; it is a list again.
- The Gradio chat's Clear / Reset now also clear the context graph.
[0.3.1.241] - 2026-10-11¶
Agent evaluation grounded in the store, and challenged on time¶
Scenario generation filled fixed templates with the first catalog rows: every skill asked about the same feature, six intents had no scenario, FEASIBILITY asked healthcare-fraud questions whatever the store held, and FRESHNESS, EXPLAIN, USAGE and feature LINEAGE carried no fact the judge could check. The facts were frozen at generation and not covered by the scenario hash, and the counts were computed by a second definition.
- Questions chosen from data (
agent/eval_scenarios.py): a feature is picked because it fits the skill (documented, produced by a process that ran, read by a dataset, registered under several entities), deterministic for aseed. Every intent is generated or listed inmeta["coverage"]with the reason (BUILD_DATASET needsinclude_writes=True, ALGORITHM needs MCP). SEARCH and FEASIBILITY questions are built from the words of the store's own documentation. - Facts from the Report's authorities (
agent/eval_facts.py): counts fromcatalog_counts, freshness in business dates with the TimeManager's date and the dates behind, lineage from the persisted graph, dataset membership byFEATURE_ID, EXPLAIN grain and filters from the view's SQL. Each turn keeps afact_spec;run_evaluation(refresh_facts='check')rebuilds the facts and flags turns whose facts no longer match the store ('refresh'judges on the rebuilt ones). The scenario hash covers the facts. - Time scenarios (category
temporal), with dates taken from the store's history: the catalog at a date between two registrations, before anything existed, a date kept by a follow-up then cleared by "now", a feature that did not exist yet, freshness and lineage at a past date, a dataset's own time, a year alone. - A judge with the agent's time rules. Each turn carries
time_context, computed with the agent's own_resolve_as_of; the agent's trace gains an**As of**line and the two are compared (as_of_match). Names that exist now but not at the turn's date (absent_names) are violations even though they are real. Checks done by code: names, counts (digits or words, EN/FR), dates (ISO or textual), violations. Faithfulness is capped at 2 on a violation, completeness at 3 when a dated answer does not state its date, robustness at 2 when the agent answered at another valid time. The report gains a Time section, the coverage list and the STALE flags. - The admin Evaluation panel passes the time, objects/columns, MCP and seed options; the generator before this release is removed.
Agent - EXPLAIN states the grain and the filters¶
An EXPLAIN answer gave the formula but not the aggregation it sits in nor the rows it is computed on.
agent/sql_context.computation_context reads them from the process view's SQL by code (reusing the sql_qualify
tokenizer): GROUP BY keys or the window, WHERE / HAVING / QUALIFY, inner joins (rows without a match dropped),
semi-joins, outer joins, a WHERE on a LEFT join's optional side (which makes it inner), and the TimeManager /
FilterManager the view reads, following the expression through derived tables and CTEs. Every EXPLAIN answer ends
with a fixed "Computed at / on which rows" block; the columns used to filter or join are listed and drillable.
Lineage at a past date, manager roles, datasets always in the graph¶
load_lineage_digraph/get_ancestors/get_descendants/get_persisted_graphacceptas_of: the base table readVALIDTIME AS OF, primary store only, never the on-the-fly build (today's DDL). The agent's LINEAGE answers at a past date from it, and says "no lineage recorded at that date" for an object absent then.- TimeManager / FilterManager views and their tables are tagged with a
rolewhen the graph is loaded (storedOBJECT_TYPEunchanged); the agent names them as drivers. drop_datasetcloses the dataset's lineage edges (lineage_store.remove_object_lineage; history kept,purgeremoves it).restore_dataset_catalogrecords the lineage of the datasets it registers or repairs, and reports catalogued datasets absent from the graph. The consistency model gainsdataset_not_in_lineage. A guard test checks that no package path builds a dataset withrecord_lineageoff.
Admin app¶
- Lineage panel: a new default Flow layout replaces the Sankey (unreadable on large workflows): left to
right, zoom and pan, the whole path of a clicked object highlighted and its documentation shown below, search,
filters (hide source tables, hide managers, processes and datasets only), managers drawn as drivers. Offline
(vis-network inlined from pyvis).
plot_lineage_sankeyremains in Python. - EFS map: a Time drivers section, the same as the Report's.
Agent defects found by the new evaluation (live, demo store), fixed¶
- USAGE invented datasets ("
_Live", " _History") for a feature no dataset reads, and matched datasets by feature name across entities. Membership is now matched by FEATURE_ID and storage table, and the prompt says explicitly when no dataset exposes the feature. - SEARCH answered "0 features registered" when the session's DATA_DOMAIN was not a real domain (the database name): such a domain now counts as none and every domain is searched.
- A feature that exists in the catalog but not in the active domain (or, at a past date, without a producing
process yet) was answered "not found - did you mean
?". The answer now lists where it is. - At a past date, the agent's whole-store scans were memoized without the valid time in their key.
- A pronoun ("what does it mean?") reached the skill as the object name "it" and answered about another object; pronouns are now a reference to the conversation.
- LINEAGE fell back to the SHOW VIEW walk (describing storage tables) when the persisted graph had nothing in the asked direction; that graph now answers ("nothing depends on X"), "what feeds it" counts as upstream, and the sources footer names the persisted graph (and its date) instead of an empty SHOW VIEW walk.
- At a date before any registration, the empty-scope answer named the database instead of the data domain asked.
- DEFINITION of a dataset now states its own data time (frozen date, TimeManager, history window), given to the model as authoritative context (the stored documentation alone made it say "no fixed period" for a history).
- A LINEAGE answer from the persisted graph could be a bare view name: the neighbours are now listed by code whenever the model's wording leaves one out, and only real TimeManager / FilterManager objects are called drivers.
Found, not fixed¶
- Feature-catalog rows registered during a TimeManager roll-out carry the business date as valid time, processes the real registration time: on a 2023 date the demo store has features but no process. The evaluation reports it as it is.
Fixes¶
- Business-dictionary writes turned French accents into
e?(_sanitize_for_teradatadecomposed with NFKD before the Latin-1 round trip). Text is now composed (NFC); only characters outside LATIN are mapped (œ->oe,€->EUR, others to their base letters or?). Rows written before keep their?until re-documented. - Migration logged "lineage refresh failed" when a dataset of the plan was deferred, skipped or failed: Phase F
named it and
resolve_objectsaborted the whole refresh. Only created objects are refreshed now, andrebuild_lineage_graph(objects=..., require_exists=False)skips unknown names with a warning.
Docs¶
TimeManager: "Several processes, one calendar: share or clone?" (when to clone, what it solves, the cautions:
a soft clone follows the original's dates, a hard clone drifts, diverging cursors mix business dates in a dataset,
how to bring processes back). Mirrored in the fs-rollout skill and notebook 05b. Consumer agent: EXPLAIN context,
lineage at a past date, evaluation. Skills ca-explain, ca-lineage, eval-inspect updated.
[0.3.1.240] - 2026-10-10¶
One count of processes and features, in the app and in the agent¶
A feature is the triplet (data domain, entity, feature name) and a process is a distinct PROCESS_ID. The Report,
the Dashboard, the EFS map and the documentation coverage counted process-catalog rows in some places and distinct
ids in others; the consumer agent counted the producers of registered features in one data domain, kept the first
producer of a feature made by two processes, and let the model write the number below the listing cap.
store_consistency.catalog_counts is now the one definition, used by every panel and by the agent, with the same
notes (features without a producer, with several producers, processes never run).
Agent - feature inventories rendered by code¶
The same question on the same store gave a different answer each time. An inventory is now rendered by code:
- the counts first (above);
- up to AGENT_LIST_MAX_ITEMS features, a table per entity, Business meaning | Technical name (the first sentence
of the documented description; an undocumented feature says so);
- above it, semantic groups (label, one sentence, technical names). The grouping is proposed once by the instruct
model, checked against the catalog (no invented or missing name), stored in the business dictionary (section
FEATURE_GROUPS of __SCOPE__<domain>[__<entity>], left out of the vector indexes and of documentation
coverage) and reused until the feature set changes. rebuild_documentation() regenerates the groups. Without an
instruct model, the features are grouped by producing process, as before.
Agent - questions at a past valid time¶
A question that names a date ("which features existed on 30 June 2023?", "au 31 octobre 2023") reads the catalogs,
the business dictionary and the dataset build tables as they were then (VALIDTIME AS OF on the base tables). The
date stays in force for the following turns until "now"; every answer at a past time starts with it. Inventories,
definitions, datasets, usage, search and freshness honour it; the other intents say they answer on the current state.
With read-only stores attached, a past date is read from the primary store only, and the answer says so. The date
never goes through FEATURE_STORE_TIME.
Agent - freshness in business dates¶
FS_FOLLOW_UP is read as an event log: followup_business_dates() gives, per process and segment, the last
business date completed (VALIDTIME_DATE, or the run time for a real-time run), when it ran, and the last failure.
FRESHNESS now answers "computed up to
TimeManager attached like the FilterManager¶
upload_features(..., time_manager=tm)records the pairing at registration (FS_TIME_MANAGER, the tableroll_outalready writes), sorun(process_id)computes at the manager's business date with no argument. Withouttime_manager=the registration statements are unchanged.- Before
run(..., time_manager=),roll_outandupload_features(..., time_manager=)use a manager, the lineage graph is read (through intermediate views) for the manager the process view really reads. New settingTIME_MANAGER_CHECK:'warn'(default) runs as before, warns and records no pairing for a manager the view does not read;'error'refuses before anything runs (a view just created fromsql_queryis dropped again);'off'skips the check. A process the graph does not know is a warning namingrebuild_lineage_graph(objects=[...]). A real-time run of a process whose view reads an undeclared manager logs a warning. time_manager_dependency(pid)(declared vs read) andlink_time_managers(dry_run=True)(record or correct the pairing of processes registered without it).- Optional registry
FS_MANAGER_REGISTRY/FS_V_MANAGER_REGISTRY: one row per TimeManager / FilterManager object, its table, and what it was cloned from. Written best effort by the managers' load / clone / take-ownership methods, ended by_drop; a failed create is a warning and is not retried.register_managers(dry_run=True)backfills it. split_time_manager(process_ids, name, clone_mode='soft', dry_run=True)gives processes their own cursor (a clone, views rewritten onto it); the inverse ofunify_time_managers. Both now refresh the lineage of the views they rewrite.
Parallel scripts - lockstep on a shared TimeManager¶
The generated execution script ran a roll_out per worker: two processes sharing a TimeManager moved one cursor from
two workers at once, so one could compute at the other's date. Processes of a wave that share a manager now run in
lockstep: the script moves the cursor one date at a time, runs them in parallel at that date with
run(pid, time_manager=tm), and waits for all of them before the next date. A failed process is not run at the
later dates.
Report - Time drivers¶
A new section lists, per process, the declared and the read TimeManager (OK, UNDECLARED, MISMATCH,
DECLARED_UNUSED, NONE, UNKNOWN), the manager's current date and the last business date computed, and the
synchronisation groups: one manager view (synchronised), soft clones (same dates, own cursors, with whether they
point at the same date), hard clones (copies), independent. Each finding names its fix.
Fixes found by running the demos live¶
- A failed storage MERGE ended the run COMPLETED.
_store_feature_mergeran its MERGE throughexecute_query, which logs and swallows errors by default: a run whose values were never stored still recordedCOMPLETED(seen with Error 3810). The MERGE now raises, and the run is recordedFAILED. - Two first runs in parallel could mix up their features. A new
FEATURE_IDis the catalog'sMAX(FEATURE_ID) + 1read by the client, so two processes registering features at the same time get the same ids; one process's values were routed to the other's storage table. The parallel planner now puts every process whose features are not all registered into one serialized unit ("feature registration (first run)"); they parallelise once their features exist. - The planner serialised processes that only share feature names. It found a process's storage tables by
(data domain, feature name), so a customer process and a category process computing
sum_Transaction_Amountwere each given the other's tables and never ran in parallel. The lookup now includes the entity when the catalog names it; a feature registered only under another entity still counts as a first registration. - The generated script reported only the first line of an error. teradataml re-raises a database error as "Encountered problem with the query. {}"; the script now reports the error and its causes (without driver stack frames).
- Agent: freshness of a process named by its view ("is FEAT_X up to date?") said "not registered"; that answer is now rendered by code (the model, given an empty list, had invented a dataset name); business dates are no longer listed as FilterManager segments; a process computed beyond its manager's current date is not called "up to date"; dataset freshness names the process views; the data-domain detail gives the process count; a data domain named in the question is not narrowed by an entity remembered from earlier turns, also at a past date when the domain did not exist yet.
- Demos:
demo_common.connectalso clears the administrator'sTDFS4DS_TD_DATABASE, so a reconnect (or a worker of a generated script) opens the persona's session in its own database (Error 3524 otherwise).
Demos¶
05 attaches the TimeManager at registration, runs without the argument and shows a refused unrelated manager. New
05b - Synchronising processes: a shared manager, a manager read through an intermediate view and recorded with
link_time_managers, a soft clone with split_time_manager and back, a hard clone. 08 attaches both managers.
A2 runs two processes on one manager with a generated script and compares their values with the sequential
roll_out of notebook 05 at every business date. G2 asks the counts, a past date and a freshness question; G3
checks the Time drivers section. New diagrams time_sync and lockstep.
[0.3.1.239] - 2026-10-09¶
Docs - entity_id is a list of column names¶
The README, the documentation, the demos and the skills said entity_id had to be a dict of SQL types
({'CUSTOMER_ID': 'BIGINT'}) and that a list was rejected. That form comes from early versions: upload_features reads
the types from the source, with their exact length and character set, and build_dataset only uses the column names.
Every example now passes a list (['CUSTOMER_ID']). A dict is still accepted, as an override of the inferred types.
Docs - skills updated from the demos; new fs-migrate and fs-federation¶
The package skills (tdfs4ds/skills, installed with tdfs4ds.install_skills()) are the reference for coding agents.
They now carry what the rebuilt demos verified on a live system:
- fs-rollout: run(time_manager=tm) computes one date (the manager's current one) and roll_out is the loop;
processes on one manager share the ingestion time and the visibility; a history is built forward from its first
date, and rebuilt with roll_out(..., time_id_start=1, force_compute=True). Without force_compute, the dates
already completed are skipped and nothing changes (checked live).
- fs-filter: the segments must split the entity; manager filters are scalar subqueries; what the execution plan
reads, measured (a single partition only when every partitioning level is pinned).
- fs-dataset: why a training set must be read at its label date, and the four time modes with real outputs.
- fs-upload: sql_query= + target_view_name= as the preferred form, run_now=False, the .env switches of the
AI steps, on_failure.
- New fs-migrate (plan, approve, migrate, rebuild the history) and fs-federation (read other stores read-only).
- Every skill connects with tdfs4ds.reconnect_teradata() from the TDFS4DS_TD_* variables; the unprefixed
TDML_* and FS_API_* variables are gone, and the data domain is activated with select_data_domain.
Demos - a notebook on the FilterManager alone¶
07 - Segmented processing with FilterManager now uses a FilterManager without a TimeManager: one category at a
time, one follow-up row per segment, and a check that the segments together equal one unsegmented computation
(8,858 rows, 0 differing values). The former 07 to 09 become 08 (FilterManager with a TimeManager), 09 (hybrid)
and 10 (process versions). The 19 notebooks pass live from empty stores.
[0.3.1.238] - 2026-10-09¶
Fixed - migrating a process driven by a TimeManager or FilterManager failed¶
Seen live while promoting two TimeManager-driven processes from a lab store to production. The production views
were never created, and the only error was a later run() failing with Error 3807 ... does not exist.
Why.
- Name prefixes. rewrite_references matched a qualified name without checking where the name ends.
- A manager is a view X over a table X_HIDDEN, so rewriting LAB.BUSINESS_DATE also hit the start of
LAB.BUSINESS_DATE_HIDDEN and produced "PROD"."BUSINESS_DATE"_HIDDEN.
- The manager view could not be created, and neither could any process view reading it.
- The same flaw sat in scheduling.unify._rewrite_view_reference, where repointing a process from filter view F
also rewrote F_COPY. It now uses the same pattern.
- Silent failure. migrate_objects ignored a failed process-view creation.
- It registered and ran the process anyway.
- The database error was visible only through the progress hook.
The fixes.
- A qualified name is now matched as a whole name, never inside a longer one.
- A process view that cannot be created is reported in report['errors'], and its process is not registered.
Fixed - a migrated TimeManager or FilterManager did not find its own table¶
Both managers found their backing table with ddl.split("\n")[-2].split(".")[1].
- What broke: a migrated manager view qualifies names with quotes ("DB_PROD"."BUSINESS_DATE_HIDDEN"), so the
table name kept its quotes. The manager did not find its table and reported nb_time_steps = None, and roll_out
failed.
- The fix: both managers now read the table after the view's FROM with the quote-aware splitter
(backing_table_from_view_ddl, shared by both).
Fixed - read federation cut the store name in FEATURE_STORE¶
The provenance column was an untyped literal, and a UNION ALL takes each column's type from its first branch.
- What broke: with the lab first, 'DB_LAB' made the column six characters wide, and DB_PROD read back as
DB_PRO.
- The fix: the literal is now cast to VARCHAR(128).
Fixed - a federated process catalog failed on every live system¶
Teradata refuses a set operation over a JSON column, even a UNION ALL (Error 3600 "Comparisons are not allowed
for the type JSON").
- What broke: the process catalog has two JSON columns (METADATA, ENTITY_NULL_SUBSTITUTE), so
process_catalog() failed as soon as a read-only store was attached. The offline tests never ran the SQL.
- The fix: JSON columns are now read as CLOB in every branch, detected from the primary view's column types.
Fixed - plan_migration left the source store attached¶
plan_migration attaches the source store read-only for its own reads, and did not detach it.
- What broke: every later read of the session was federated with the source. In the production session, a
build_dataset then referenced a view of the source store, and failed with Error 3523 because the production
database has no WITH GRANT OPTION on the lab.
- The fix: the caller's read-only stores are restored when the plan returns, including after an error.
migrate_objects reads the source by explicit schema names and does not need the attachment.
Fixed - a migrated process ran in real time instead of at its TimeManager's date¶
The migration recreated a process's TimeManager but did not record it as the process's TimeManager.
- What broke: the recompute that follows (run()) ran in real time and stored today's value. A history replayed
afterwards with roll_out over past dates left nothing before it (see Known limitation below).
- The fix: the migrated TimeManager (the placement of the source manager's view) is now recorded for each
migrated process, so run() uses its business date, as in the source store.
Added - data_strategy='none' for plan_migration / migrate_objects¶
Registers the processes without computing them, for a primary that replays its own history with roll_out.
Datasets on such processes are reported in report['deferred'] and built afterwards, because their features only
reach the feature catalog at the first run.
Fixed - a dataset on another store's features vanished from the lineage graph¶
When the lineage builder expands a dataset, it replaces the dataset's edges to its storage tables with edges to the processes found in the store's catalog. - What broke: for a dataset built on a read-only store's features, no process is in that catalog. The dataset was left without any edge, and the graph stores edges only, so it disappeared. - The fix: when no process resolves, the edges to the storage tables are kept.
Changed - run(..., on_failure='raise') raises for an unknown process¶
- Default: a
process_idmissing from the primary catalog still logs an error and returnsNone. - With
on_failure='raise': it now raisesLookupError. The process may, for example, belong to a read-only store, whichrun()never executes. - Why: with
DISPLAY_LOGSoff, theNonelooked like a run that had gone through.
Fixed - the agent described a dataset with features of other domains¶
The agent matched a dataset's features to the active domain's catalog by name, and kept the first match.
- Seen live: for a one-entity dataset, the answer said "exposes 2 entities (Category: 2, CustomerID: 2)" and cited
another domain's processes, because the same feature names exist there under two entities.
- The fix: the features are now resolved by FEATURE_ID, which identifies the data domain, entity and name.
- Process: the process is the one of that domain and entity whose feature list names the feature.
- Several candidates: the dataset view's FEATURE_VERSION decides.
- Fallback: the name match is still used when a feature id is unknown.
- Guarded by tests/test_dataset_feature_identity.py.
Known issues - Teradata Vector Store (observed while building the demos, not changed)¶
- Shared collection names. Collections are named
<TERADATA_VECTOR_STORE_PREFIX>_objects/_sections/_columnsfor the whole service. Two stores on one service with the default prefix share them. - Seen: a migration's index refresh on a second database failed to refresh
tdfs4ds_columnsin place, and the collection was recreated on that database. The first store's column index was lost, and its temporary tables were left behind. - Workaround: give each store its own prefix.
- Refreshing a collection on another database. Refreshing a collection whose target database is not the default
database of the Vector Store login failed with
Object 'collection_<name>_index' does not exist. - That error is reported as "service unreachable or not enabled", which it is not.
build_vector_index(force_rebuild=True)(destroy and recreate) works.
Known limitation - a history cannot be replayed behind an existing value¶
This was observed live, not changed.
- Scenario: a process has a value stored from instant T, and is then run at earlier valid times (for example a
roll_out over past dates).
- Result: each merge only updates the part of its period that overlaps the existing row. Nothing is inserted
before T.
- Workaround: build a history forward, from its first date, on a store with no values yet for those features.
Fixed - write_migration_script dropped the per-table data choice¶
A source table can be copied into production (the default) or read in place (data_choice='reference', set on the
reviewed plan). The generated script plans again before it runs, so it silently went back to copying. The script now
records each table's choice and re-applies it after planning.
Fixed - a misleading warning on the first TimeManager.load_time_steps¶
The first load logged WARNING Error dropping table ... does not exist. There is nothing to drop on a first load,
so this is now a debug message; any other failure to drop is still a warning.
Changed - demo notebooks rebuilt for the current API¶
demos/ is rewritten:
- a setup notebook creates the personas (data scientist, data engineer) and the databases (source, lab, production),
and loads a partitioned source table;
- core notebooks 00 to 09;
- a production track (promotion, read federation);
- an operations track and an agentic track;
- generated concept diagrams, and assertions that fail the run on a wrong result.
See demos/README.md.
Guarded by tests/test_migration_rewrite.py, tests/test_migration_planner.py, tests/test_manager_backing_table.py,
tests/test_read_federation.py and tests/test_failure_reporting.py.
[0.3.1.237] - 2026-10-09¶
Fixed - "what are the available features?" answered about a data domain the store does not have¶
Seen live. On a store with a single data domain, the agent listed that domain, then answered the next question with "No features are registered in the claims_anomaly_detection data domain." No such domain exists in that store.
Why.
- Copied example. The classifier prompt carried a fixed example taken from a demo store: "What features are available for claims_anomaly_detection?" → domain=claims_anomaly_detection. The model copied that domain into its answer to a question that named none.
- No check against the store.
- A domain the store does not have still counted as a scope.
- So did a remembered domain (a thread that started on another database) or a session DATA_DOMAIN written for another database.
- So the single-domain shortcut was never reached, and the answer named the invented domain.
The fixes.
- Examples come from the connected store.
- Every classifier example (the built-in ones and the ## Classifier Examples of each ca-* skill) now uses placeholders: <domain>, <domain2>, <feature>, <feature2>, <feature3>, <entity>, <dataset>, <view>.
- They are filled with real names read from the turn's catalog snapshot, at no extra cost.
- An example whose name the store cannot supply is dropped, never shown with a placeholder.
- No fixed names left.
- Store-specific names were also removed from the classifier's field descriptions, the system prompt, the multi-feature definition prompt and one suggestion shown to the user.
- A test lists the names of the old demo stores and fails if one reaches the classifier again.
- Only real domains scope an answer.
- The summary skill and the domain-detection node now keep a domain only when the store has it: a classifier guess, a remembered domain or the session DATA_DOMAIN.
- With a single data domain, that domain is used. With several, the agent asks which one.
- The "no features" message names only a real domain.
Guarded by tests/test_store_examples.py.
Added - default AI steps of upload_features from the configuration¶
What changes.
- upload_features(document=, explain=, optimize_query=, vector_indexing=, cascade_redocumentation=) now default to None, which reads TDFS4DS_UPLOAD_DOCUMENT, _EXPLAIN, _OPTIMIZE_QUERY, _VECTOR_INDEXING and _CASCADE_REDOCUMENTATION.
- TDFS4DS_AGENTIC_FEATURES=false turns all of them off at once.
- An explicit argument always wins.
- explain is new. It skips the LLM analysis of the EXPLAIN plan while keeping the documentation (document_process_incremental(explain=False)), and the stored analysis is left unchanged.
Compatibility. The defaults are the previous behaviour (documentation and EXPLAIN analysis on, the rest off), so a configuration that sets nothing is unchanged.
Datasets in the lineage graph. This was already the default, and a test now guards it.
Guarded by tests/test_upload_ai_switches.py.
[0.3.1.236] - 2026-10-08¶
Fixed - the consumer agent took ~30 s to list features¶
What was slow. Measured on the demo store, a "what are the available features?" turn took about 30 s. The LLM accounted for 2 s of it; the rest was catalog reads.
- teradataml objects: a teradataml DataFrame spends about 1 s on metadata calls before reading a row.
- Dataset catalog accessors: dataset_catalog() and get_dataset_features() each built a DatasetCatalog, whose constructor listed the database (about 4 s per call).
- Repeated reads: the turn read the catalogs 8 to 10 times that way. The same rows come back in 0.1 s with raw SQL.
The fixes.
- One request per turn:
- the agent reads the catalogs in a single multi-statement raw-SQL request, about 0.3 s, memoized for the turn;
- the statements: feature, process and dataset catalogs, dataset features and entity, feature-column descriptions, process documentation;
- every catalog read of the agent (about 40 call sites) is served from it;
- the run history it needs for freshness is read in raw SQL too.
- Valid time: the request states CURRENT VALIDTIME, so the agent no longer depends on the session's temporal default.
- Read federation: with read-only stores attached the snapshot is not used, and the federated accessors keep their behaviour.
- DatasetCatalog (package-wide):
- it checks that its objects exist once per process instead of on every construction;
- an explicit create_catalog() still re-checks;
- its three DataFrames (catalog, entity, features) are built on first use.
Measured on the demo store.
| Turn | Before | After |
|---|---|---|
| listing | ~30 s | ~4 s (the LLM about 2 s) |
| freshness | 5.1 s | 2.6 s |
| database time per turn, all skills | — | 0.2–0.7 s |
[0.3.1.235] - 2026-10-08¶
Fixed - the consumer agent invented themes for large feature lists, and could not narrow them¶
Reported from the field on a 362-feature store.
- What happened: "which features are available?" came back with themes, and even feature names ("STD_CORR_NB (si présent)"), that the store does not contain. Asking for a subgroup ("the features of entity X for corrections") repeated the same overview.
- Why: above AGENT_LIST_MAX_ITEMS the LLM received counts and three example features per entity, and was asked for "3 to 5 themes".
What large listings show now. The answer is built in Python from what the store records; no LLM text.
- Overview: features grouped by the process that computes them. Each group shows:
- the first sentence of the process's stored documentation (business logic and entity description), or "(not documented)";
- a few names;
- suggestions that name real processes, never the scope just asked.
- Subject in the question ("features of entity X about corrections"):
- the LLM picks the matching features from the scope's documented descriptions;
- every returned name is checked against the scope, and unknown names are dropped and reported in topic_dropped;
- the matches are listed in full with their descriptions;
- a subject with no match says so.
- Process scope: "features of process V_…" lists everything that process computes. Before this release a process view name was not recognised and the whole domain was listed.
- Below the cap: listings are unchanged.
- Follow-up questions: after a subject turn, follow-ups ("are these up to date?") apply to the selection.
Added - the agent states each dataset's own time¶
Two times.
- The metadata plane (what is registered and documented) is read at the current valid time.
- Each dataset has its own data time. Datasets are now:
- snapshots at the current valid time (the only kind before 0.3.1.216);
- snapshots at a fixed date (as_of=, or legacy feature_store_time=True);
- snapshots at a TimeManager's business date (moves with tm.update());
- historical datasets (one row per period).
What the agent says.
- Which answers carry the dataset's time:
- SUMMARY on a dataset;
- DATASET (each dataset is tagged, current snapshots first);
- USAGE (for a historical dataset the snippet selects ValidStart / ValidEnd and shows how to read one instant);
- FRESHNESS on a dataset (a fixed snapshot is "frozen"; a TimeManager dataset is stale when its processes have not completed a run for the manager's business date).
- Where the time is read from:
- the build/window rows, read straight from their public views (never through the dataset catalog constructor, which creates missing objects);
- otherwise the view's DDL, which is how a dataset registered before the build table existed is read.
- Current snapshots get no line, so a store from an earlier version answers exactly as before.
- HELP explains the two times.
The metadata stays current. _recognize_object_type no longer calls get_list_entity, the one agent read that followed tdfs4ds.FEATURE_STORE_TIME (an ingestion setting). A source-level test guards the agent modules.
Changed - feature documentation names the entity by its meaning¶
- The rule: the documentation prompt says a feature description refers to the entity by its business meaning ("for each study"), never by its key columns ("per STD_CDH_ID").
- Retry: a description that still names a key column triggers the existing single correction retry, with the entity description in the note. A formula violation and a key leak still share one retry.
- Existing documentation changes only when the process is re-documented (
rebuild_documentation(objects=[...], force_update=True)or the Documentation panel).
Added - Report: each entity column marked as primary index / partitioning / neither¶
- The inventory tree tags each entity column
PI(primary index) orP(partitioning column) of the entity's storage tables.PI*/P*means only some of the tables agree. - The tags come from one batched data-dictionary read.
- A legend explains them, and when the dictionary cannot be read the tree says so.
Fixed¶
skill_freshnessraisedUnboundLocalErroron every cache hit.skill_usagenever put the entity keys in its SELECT snippets: it readENTITY_ID, but the feature catalog column isENTITY_NAME.- Dataset freshness compared bare and qualified process view names; it now matches on the object name.
Offline: tests/test_large_listing.py (rewritten), tests/test_agent_valid_time.py, tests/test_doc_entity_meaning.py, tests/test_store_consistency.py.
Live, on the demo store:
- the overview;
- subject selection (EN and FR);
- the process scope;
- as_of and historical datasets, read from the build row and from the DDL only (throwaway datasets, dropped);
- the entity key roles.
Not done: the time line in LINEAGE answers on a dataset node.
[0.3.1.234] - 2026-10-08¶
Fixed - the admin app's feature counts disagreed, and none said what it counted¶
Reported from the field: on one store the Report showed 362 features in its KPIs and 337 in its inventory tree, the EFS map 362, and the Documentation coverage panel 346.
- One counting rule: a feature is (data domain, entity, feature name).
- The inventory tree badged a domain with the number of distinct names, so a name registered under two entities counted once (the 337).
_distinct_feature_countnow includes the data domain when the frame has one. On a single-domain frame its result is unchanged. The consumer agent's ungrouped counts on an all-domain frame (search, summary with no domain selected) used to undercount a name repeated in two domains; they no longer do.- Removed features were counted as current.
FS_V_FEATURE_CATALOGis the only catalog view withoutCURRENT VALIDTIME. A session whose temporal qualifier isANSIQUALIFIER(verified live) reads it nonsequenced, sofeature_catalog()also returned closed rows: 16 instead of 14 on the test store, the 2 extra being removed features whose table no longer exists. - The Report, the EFS map and the coverage note now read the base table
CURRENT VALIDTIME. With read-only stores attached they keep the federated accessor. feature_catalog()(list_features) now keeps the rows valid now when the view comes back withValidStart/ValidEnd(the ANSI case), using a plain predicate. A session whose default isCURRENTalready returns current rows only and reads the view exactly as before. Read federation is unchanged.- A new feature could reuse a removed feature's ID.
GetTheLargestFeatureIDreadMAX(FEATURE_ID)through the view, which aCURRENT-default session limits to current rows. A removed feature's ID, whose values may still sit in itsFS_T_*table, could then be given to a new feature. It now reads every version of the base table (NONSEQUENCED VALIDTIME), which is always at least what it returned before. - The view itself is unchanged: registration joins it, and the per-feature value views are built
SEQUENCED VALIDTIMEover it. - The documentation coverage card is "Features (process outputs)". It counts one row per name a current process lists, because documentation lives on the process view's column. A sub-line now reconciles it with the catalog:
362 in the catalog · N without a current producer (not documentable here) · M listed by several processes. - Remarkable facts:
- the richest and leanest datasets were shown by DATASET_ID (the dataset-features view has no name column); they now show
db.name, counted by FEATURE_ID; - the refresh cadence measured the gaps between the FilterManager segments of one execution ("every 0.0 h"); it now measures the gaps between executions (RUN_ID, VALIDTIME_DATE).
Added - catalog valid time, and consistency & orphans in the Report and the EFS map¶
- Catalog valid time.
- Both panels gain a valid-time input: blank = current, otherwise a date-time read as UTC.
- Every catalog count states the valid time it was read at.
- A date reads the primary store's base tables
VALIDTIME AS OF. The process-catalog SELECT is now one helper (process_catalog_select_sql) shared with theFS_V_PROCESS_CATALOGview, whose text is byte-identical. - Routes
/ui/dashboard/report,/ui/dashboard/report/download,/ui/efsand/ui/efs/downloadtakeas_of=. - New module
serve/store_consistency.py. It holds the snapshot, a pure consistency model and the renderer. The model reports: - features with no current producer, listed by several processes, retired since registration, or whose storage table is missing;
- processes whose view is gone, whose features are not registered, that never ran, or whose features no dataset reads;
FS_T_*tables no catalog row points to (or only retired ones);- datasets reading feature ids the catalog no longer has, or whose view is gone.
Each finding names the command that fixes it; nothing writes. A reconciliation line explains every feature number.
- Report.
- New Consistency & orphans section.
- KPI sub-lines (4 retired · 2 without producer, 3 never run, 1 unreferenced).
- A Scan storage for orphans button: SSE /ui/dashboard/orphans, one grouped FEATURE_ID, COUNT(*) read per storage table, all versions and at the valid time. It finds FEATURE_IDs stored without a catalog row (known in the history, or never registered) and registered features with no value.
- EFS map.
- Orphan-ID badges on the table cards once a scan exists.
- An Unreferenced storage tables block: the map only knew the catalog's tables, so these were invisible.
- KPI sub-lines and the reconciliation line.
- Table and view existence is read from the data dictionary now, whatever the valid time; physical row counts keep their own time base (statistics or exact counts). Offline: tests/test_store_consistency.py.
[0.3.1.233] - 2026-10-08¶
Fixed - batch lookups now reach the storage tables' primary index and partitions¶
Reported from the field: the benchmark's multi-key batch phase ran 64 times over its estimate on a 10-column grouped dataset whose storage tables are partitioned on FEATURE_ID and an entity column.
- Keys pushed into every storage-table read.
retrieve_batchand the benchmark used to filter the dataset view (SELECT * FROM view WHERE <keys>). Under FULL OUTER the view's entity is aCOALESCEover every sub-query, so nothing can be pushed through it; in a grouped view only some sub-queries received the keys. EXPLAIN on a live 3-group dataset: the view form filtered the first group and aggregated the other two whole (estimated 4,989 and 4,277 rows) before applying the keys, while every pushed-down read filters beforeGROUP BY(10 rows each). The dataset's query is now regenerated from its own DDL (ddl_spec.render_retrieval_sql, the same renderer as the view) with the key predicate - or the join to the volatile key table - inside eachFS_T_*read. Newretrieve_batch(pushdown='auto'|True|False);Falseis the previous SQL byte for byte, and historical or unparseable views fall back to it. - Partition elimination on entity columns. When a storage table is partitioned on an entity column, a redundant
B1.<col> IN (<distinct values>)is added to its read (implied by the keys, so no result changes); the optimizer derives static partition elimination from it, not from an OR of ANDs or a join. One rule,partitioning_entity_columns, now serves both this and the statistics check. - The volatile key table gets the real primary index. The storage tables were looked up in the process view's DDL (the user's source view), so none was found and the key table always fell back to the full entity as its PRIMARY INDEX. They are now read from the dataset DDL.
- Date and timestamp keys matched nothing.
_fmt_valrendered a date ascol = 2016-01-01(integer arithmetic), so every in_list and single-key lookup on a date-keyed dataset returned no row - the benchmark timed empty lookups. Dates, timestamps and times now render as typed literals. - Benchmark: the batch, large-batch and batch-strategy phases call the shared builder (
build_batch_sql), so they measure whatretrieve_batchruns;run_benchmark(batch_pushdown=False)measures the old form for comparison, and the report states the query shape and, per storage table, its primary index and partitioning entity columns. - Live-verified: rows identical to the view form for 6 registered datasets and for INNER/FULL OUTER x multi-join/grouped (with and without the partition IN-list, including a key that matches nothing), both strategies. Offline:
tests/test_batch_pushdown.py.
[0.3.1.232] - 2026-10-08¶
Added - tdfs4ds.configure_interactively(): guided, non-destructive .env setup¶
- Asks for the database connection, the instruct model, the embedding model and the vector store, and updates the existing
.envline by line throughpatch_dotenv. Comments, ordering, commented alternatives and unrelated variables survive; values already in the file are the defaults (Enter keeps them); a secret is shown asset (hidden, N chars).generate_ai_studio_envis unchanged and still rewrites its file. - Models are listed by the provider's own mechanism: HTTP
/modelsfor the OpenAI-compatible family,boto3for Bedrock (foundation models plus inference profiles, since many models can only be invoked through a profile id). Azure, Vertex and LiteLLM have no list call in the package, so the model is typed and the live check decides. - An API key is asked for only when needed. The wizard first tries without one (cloud role, profile, keyless gateway) and asks only after an authentication failure, either when listing or at the live model call. A keyless OpenAI-compatible endpoint is recorded as
EMPTY, which the SDK accepts. - The Teradata Vector Store may be unavailable (service not enabled,
teradatagenaimissing, old version). The wizard runs the existing auth and service checks and offers local Chroma instead of writing a backend that cannot work. - Secrets are written only after an explicit yes (
write_secrets='ask'|'never'|'always', default asks with no), in a separateallow_secrets=Truecall. Nothing secret is printed or returned. - Fixed:
INSTRUCT_MODEL_REGION,INSTRUCT_MODEL_AZURE_*,INSTRUCT_MODEL_GCP_*and theEMBEDDING_MODEL_*counterparts could not be set from a.env(only from Python), so a Bedrock region could not be persisted. They are now registered config variables. - Guarded by
tests/test_configure.pyandtests/test_configure_bedrock.py(offline).
Added - AWS Bedrock through its OpenAI-compatible endpoint, with an API key or the environment's role¶
INSTRUCT_MODEL_PROVIDER=aws(a spelling ofbedrock) orbedrock, withINSTRUCT_MODEL_URLending in/openai/v1(for examplehttps://bedrock-runtime.eu-central-1.amazonaws.com/openai/v1), now speaks the OpenAI protocol to Bedrock. Authentication is the API key as the bearer token, or - withINSTRUCT_MODEL_CLIENT_MODE=ambient, or no key - SigV4 signing with the ambient AWS credential chain (instance or task role, SSO, profile). New settingsINSTRUCT_MODEL_CLIENT_MODEandEMBEDDING_MODEL_CLIENT_MODE. The routing and key rules live in one place,tdfs4ds/utils/llm_provider.py.- Backward compatible: a
bedrockconfiguration without such a URL builds the sameChatBedrockConverseclient with the same arguments as before (asserted intests/test_llm_provider.py). One deliberate exception: abedrockconfiguration that already carried an.../openai/v1URL, which used to be ignored, now takes the OpenAI-compatible route. - Fixed: a role-based setup (Bedrock, Vertex) could never document anything -
document_sql_query_columnsand the two other documenters refused to run withoutINSTRUCT_MODEL_API_KEYfor every provider. They now ask for a key only where one is required. check_configuration()names how a Bedrock or Vertex call authenticated (never a value), and anAccessDeniedis reported as a permission problem (bedrock:InvokeModelon the model or inference profile) instead of "check the API key". The JSON-constraints sweep signs its requests like the client does. A Bedrock embedding provider left on the OpenAI default model name is called out.- The Converse client also resolves its region from
AWS_REGION(an explicit setting orAWS_DEFAULT_REGIONstill wins). - The wizard asks Converse or OpenAI-compatible, then role or key; a role that may call a model but not list models is not asked for a key - it types the model id (several candidates are tried in turn) and is offered the
eu./us./apac.inference-profile form when the bare id is refused.
[0.3.1.231] - 2026-10-07¶
Changed - Execution plan and Migration marked BETA in the admin app¶
- The rail shows a BETA badge on Execution plan and Migration, as on Evaluation.
[0.3.1.230] - 2026-10-06¶
Fixed - the consumer agent forgot the conversation when the MCP toggle changed¶
- Each compiled agent graph (one per skill set, plugin set and MCP setting) had its own conversation memory (
MemorySaver). Turning MCP on mid-conversation, for example to sample a dataset after a few turns, switched to a graph with no memory for that thread. The agent lost the earlier turns, and the knowledge graph shrank to the current turn. All graphs now share one checkpointer, so the memory follows the thread, and the knowledge graph only grows within a conversation.reset_consumer_agent()rebuilds the graphs but keeps the memory; Reset Conversation (a new thread id) still starts fresh. Guarded bytests/test_conversation_memory_shared.py.
[0.3.1.229] - 2026-10-06¶
Added - Documentation section in the admin app; left rail grouped by theme¶
Documentation → Coverage / Objects (serve/doc_coverage.py, routes
/ui/doc/*): what is documented, what is missing, and what is in the vector
index.
- Objects: processes and each feature column, datasets (description + the five
sections), intermediate views and source tables of the lineage graph.
Statuses: complete / partial / missing. A blank description is missing, and
documentation outside the expected slots (a custom section, documented
columns) makes an object partial.
FS_T_*and*_HIDDENobjects are not counted. Source tables are flagged user-supplied (tdfs4ds never generates them). Documentation of objects the store no longer has is listed as orphans. - Lineage nodes absent from
DBC.TablesVare left out and listed. The live graph held SQL tokens stored as object names (1,APPLY,POSITION); they had shown up as views missing documentation. Fixing the lineage builder is not part of this change. - Vector index status per documented row:
- Chroma: exact.
vector_index.chroma_index_statusbuilds the expected documents with the build's own builders. It compares them throughclassify_stored, the one comparison now shared with the build's skip of unchanged rows (_unchanged_ids). Rows are indexed, stale or not indexed; stored ids no longer documented are orphans. A collection is opened without creating it and without an embedding client. - Teradata Vector Store: inferred, and labelled so. A row is indexed when
the collection is
readyand the row'sValidStartprecedes the collection'slast_updated, and pending otherwise. A missing, failed or building collection is shown as such. The backing tables are never read, becauseTD_IDis a row position. - Coverage panel: KPIs per kind, one line per collection, gap lists linked to the objects, and a CSV export of the gaps.
- Objects panel: filters, row selection, and object detail. The detail shares
_object_documentation_mdwith the Lineage node detail (extracted from it, same output). - Actions, streamed: Document missing / selected (process, dataset,
intermediate view; one call per process; source tables skipped), then
build_vector_index(force_rebuild=False); Update vector index. A long selection is parked throughPOST /ui/doc/selection, because EventSource can only GET. - No feature-store database selected now gives a plain message instead of a
teradataml type error (seen when the server started without
TDFS4DS_SCHEMA).
Rail grouped by theme:
- Sections: Assistant, Explore, Documentation, Operations, Performance,
Settings.
- Every entry has a one-line description, shown on hover. It is positioned in
JS because the rail scrolls and would clip a CSS tooltip; title stays as
the fallback.
- Sections collapse when their heading is clicked. The collapsed state is
remembered in localStorage, which is optional. The section holding the
panel in the URL always opens.
- A test checks that every rail entry has a registered panel and a description.
Live-checked on a store with real documentation and the Teradata Vector Store. - Figures: 38 objects; 6 documentation gaps; 32 index gaps, because one collection does not exist and another was last updated in July. - Phantom nodes: 3 lineage nodes left out. - Chroma: the exact status, run against a local index, matched the documentation tables' counts exactly. - Browser check (Edge driven by Playwright): tooltip, collapse, reopening on boot, gap link to object detail, filters; no page errors. - Not run live: the Document / Update index actions, since they write.
Also corrected: the rebuild_vector_indexing and build_vector_index
docstrings still described the Teradata behaviour from before 0.3.1.167 / 169.
Fixed - raw Markdown in the admin app (lineage node detail, reports)¶
- The admin panels render documentation with the
markdownpackage, which was imported but declared nowhere. An install without it fell back to the raw text in a<pre>block, logging only at DEBUG.markdownis now in theserveextra, and the fallback logs a warning and states the reason above the text. td.cssis loaded with?v=<version>likeapp.js, so a browser does not keep an old stylesheet after an upgrade.
[0.3.1.228] - 2026-10-06¶
Fixed - Vector Store collections turned update_failed right after a successful build¶
Cause of the field report left open in 0.3.1.225, now established from a
customer's log and collection listing: rebuild_vector_indexing(force_rebuild=True)
created the three collections, which reached ready; the MAJOR "finalize"
update tdfs4ds sends after every build (collection.update(update_style="MAJOR"))
then turned two of them into update_failed within seconds, the third being
still update_started. The build logged "MAJOR finalize pass complete", and
the next tdfs4ds.serve start found failed collections.
- The MAJOR finalize pass is removed. It was added in 0.3.1.139 to
consolidate after
add_datasets(update_style="MINOR"), and no build has calledadd_datasetssince 0.3.1.169: a collection is created from scratch or refreshed (update(data_refresh=True), else destroy + recreate), and neither needs it. The self-test drops itsmajor_finalizestep for the same reason (it only checked that the update was accepted, so it passed on the affected service). A source-level test guards that noupdate_style="MAJOR"call remains. - A write is confirmed only by a status that follows it.
_verify_write_succeeded(before=...)no longer accepts thereadystatus still reported for a moment after an update is accepted:readycounts oncelast_updatedhas changed or a non-ready status was seen (after a few polls when the service gives nolast_updated). This race is why 0.3.1.225's check logged "complete" for a pass that failed. Used on the refresh path; a create needs no baseline._lookup_store_infonow returnslast_updated.
Not live-tested (no affected Vector Store service available); offline tests replay the customer's status sequence.
[0.3.1.227] - 2026-10-06¶
Changed - one valid start per run; run() uses the process's TimeManager¶
Run processes with a TimeManager or with tdfs4ds.FEATURE_STORE_TIME set: it
is the only way several processes share one valid time, and the only way values
computed for a business date carry that date. Real time is a fallback. Two
gaps made the fallback, and manual stepping, worse than they needed to be:
- A real-time run stamped each feature separately. Without
FEATURE_STORE_TIME, each feature was merged by its ownCURRENT VALIDTIMEstatement with its own clock reading, so features of one run started about a second apart, and a historical dataset showed that second as a period. A real-time run now reads the database time once and every feature and every FilterManager partition of the run uses it. Checked live: one run, 48 rows, 8 features, one valid start. The follow-up row still recordsVALIDTIME_DATEas NULL, so per-segment freshness is unchanged. Rows written this way end at9999-01-01like anyFEATURE_STORE_TIMErun, rather than at9999-12-31 23:59:59; both read as open. run()alone ignored the process's TimeManager. Aftertm.update(time_id=i),run(pid)computed the manager's business date but stamped it with the current time unlessFEATURE_STORE_TIMEwas set by hand.run()now decides the valid time once, in this order:FEATURE_STORE_TIMEif set; then a newtime_manager=argument (the pairing is recorded, asroll_outrecords it); then the TimeManager recorded for the process; then real time. With a TimeManager,FEATURE_STORE_TIMEis set for the run (the follow-up records the date) and restored afterwards. A recorded manager that cannot be read is a warning, never a failure.roll_outleft the last rolled-out date inFEATURE_STORE_TIME. A laterrun()orbuild_datasetin the same session silently used it.roll_outnow restores the previous value, also when a process fails.
upload_tdstone2_scores also uses one database timestamp when
FEATURE_STORE_TIME is not set. Generated parallel-execution scripts are
unchanged (they drive processes with a TimeManager through roll_out).
All five cases were checked live on Vantage 20 (demo store from demos/):
real time; recorded TimeManager; time_manager=, with the pairing recorded;
an explicit FEATURE_STORE_TIME winning over the recorded manager; and
roll_out restoring the global.
Found while testing, not changed: the STORE_FEATURE='UPDATE_INSERT' storage
path does not work. Its table loop reads the table and database names from a
groupby().count() frame that only holds the count, and it fails when
DISPLAY_LOGS is on. The default 'MERGE' path is unaffected.
Fixed - setup() on a system without temporal tables: one clear error, no false "created"¶
Reported from the field (Vantage 20.0.0.69): every catalog CREATE failed with
"Temporal operations are not supported on this system", and the log showed
each failure twice, each followed by about twenty lines of the driver's Go
stack trace, then a secondary index attempted on each missing table
(Error 3807), then "Feature catalog table created", "Process catalog table
created" and so on for tables that did not exist.
setup()first checks that a VALIDTIME table can be created (a session-only volatile table, the probecheck_configurationalready uses). When the probe fails for a temporal reason, it stops before creating anything, with one error naming the cause and the remedy (ask the DBA to enable the Temporal feature). Any other probe failure only warns, so a transient problem in the probe never blocks a store that would work.describe_db_error(exc)(utils/query_management.py) drops the driver's stack frames (at … .go:NN) and keeps every message line. Messages are still not cut to their first line: the line that names the fault or the fix is often a later one. Used byexecute_query,create_object_if_missingandsetup().- Each failure is logged once:
execute_query(log_errors=False)lets a caller that re-raises and logs itself (create_object_if_missing, the follow-up table, theif_exists='replace're-create) skip the generic "Error executing query" line. - The feature and process catalogs skip their secondary index when the table failed, instead of adding an Error 3807 that hides the cause.
setup()no longer announces "… table created" after each step. The creation helpers already log what they created, found or failed to create, andsetup()now ends with one checked line: "Setup complete: the catalog tables exist in", or "Setup incomplete: N catalog table(s) missing: …", read from DBC.TablesV. A failing dataset catalog is reported instead of raising out ofsetup().
Live-checked on Vantage 20 (a store with temporal support): a real driver error
went from 22 lines (19 stack frames) to its one message line, and setup() on
an existing store logged each object once as "already exists", ended with
"Setup complete", and left the catalog tables untouched (same
CreateTimeStamp). The no-temporal path is covered offline, replaying the
field error (tests/test_setup_error_reporting.py).
[0.3.1.226] - 2026-10-06¶
Added - historical datasets: build_historical_dataset¶
Every dataset so far was a snapshot: each entity's feature values at one valid
instant. tdfs4ds.build_historical_dataset(...) returns their history over
a window instead — one row per entity per period during which its feature
values did not change:
- Still a view. Nothing is materialised: new ingestions show up at once, and a relative window moves by itself.
- The window: the full history by default, or
start/end(date or timestamp), or adurationand adelayrelative to the current valid time (duration='12 months', delay='1 day'=[now - 1 day - 12 months, now - 1 day)). Durations accept'3 months','90 days','1 year 2 months', ISO 8601,(3, 'months')or atimedelta; months useADD_MONTHS, so 31 May minus 3 months is 29 Feb.nowis evaluated when the view is queried, and the end is always defined: a period still open in the store ends at the window's end, not at 9999-12-31. - Join types.
INNERkeeps a period only where every feature has a value;FULL OUTERkeeps every period where at least one has, with NULLs elsewhere. At any instant of the window the history equalsbuild_dataset(as_of=<instant>)with the same join type. - Consecutive rows always differ. Equal neighbours are merged with
NORMALIZE ON MEETS OR OVERLAPS. tdfs4ds' own ingestion does not create them: re-writing an unchanged value leaves the stored period intact (checked live, in real time and at an explicit valid time), and on the demo store the merge found nothing to do. It guarantees the property for histories that do hold equal neighbours, for example rows written outside tdfs4ds. - Catalog. Registered like any dataset, with
TIME_MODE='HISTORY'in the build table.parse_dataset_ddlreads the view back, sorebuild_dataset(new argumentsstart/end/duration/delay/history: turn a snapshot into a history, move a window, or go back to a snapshot),restore_dataset_catalogand cross-store migration handle it. - Window table
FS_<catalog>_WINDOW/FS_V_<catalog>_WINDOW, read bytdfs4ds.get_dataset_window(): one row per historical dataset, joined to the catalog onDATASET_ID(LEFT OUTER JOIN — snapshots have no row). Typed columns:START_KIND(FIXED/END_MINUS_DURATION/EARLIEST),END_KIND(FIXED/START_PLUS_DURATION/NOW),START_TS,END_TS, duration and delay as ISO 8601 and as months + seconds,SLIDING,WINDOW_TEXT. The view addsWINDOW_START/WINDOW_END, the bounds when queried, so a sliding window shows real timestamps. Live-verified on Vantage 20: for all eight window shapes they equal the bounds the dataset view computes, in the same statement. A separate optional table, like_BUILD: noALTERof an existing catalog, older versions ignore it, a user withoutCREATErights gets a warning.restore_dataset_catalogreports a history without its row asREPAIRand writes it; a history rebuilt in place as a snapshot has its row closed.AS_OF_TIMESTAMPkeeps meaning an as-of instant only (NULL for a history). Two findings on the way:CURRENT VALIDTIMEover a derived table is Error 9330, so the view is one level with the end expression repeated; an interval cannot be built from a column with aDAY TO SECONDliteral, so the seconds are split intoDAY(4)/HOUR/MINUTE/SECONDcasts (checked to 9999 days 23:59:59). Each optional view is now skipped only when its own table failed, not when any optional object did.
How the SQL was chosen, from EXPLAIN plans read on Vantage 20:
INNERuses Teradata's nativeSEQUENCED VALIDTIMEjoin, which intersects the valid periods itself: each storage table is read once from a single partition, the joins are rowkey-based merge joins, and even a sliding window is folded into the join step as constants.- A sequenced outer join is refused (Error 9347), so
FULL OUTERcuts each entity's timeline at every change point and attaches each feature on containment. It reads each storage table twice, and is about twice as slow on the tested data. The change points come from one read per feature emitting both bounds: against reading starts and ends separately, it gave the same rows with 3 storage reads instead of 8 and 2 redistributions instead of 10. NORMALIZE ON MEETS OR OVERLAPSwas preferred to theTD_NORMALIZE_OVERLAP_MEETtable function. The function needs exactRETURNStypes and only works behind aWITHclause; fed by a derived table, it silently multiplied the rows (3 rows in, 6 out).
Verified live on a feature store built from the demos/ notebooks: for three
cases (customer features, category features, and category features under three
process versions), both join types were created as views and returned exactly
the rows of an independent reference computed from the raw storage rows, and
they matched build_dataset(as_of=...) at sampled instants. On the
three-version case FULL OUTER returned 112 rows and INNER 75: INNER starts each
history when the latest process first wrote a value.
Observed while testing, documented and not changed:
- In real time (no
FEATURE_STORE_TIME), ingestion merged each feature with its own statement and timestamp, so features of one run could start a second apart; a combined history shows that second as a period (one valid start per run from 0.3.1.227). - A float aggregate recomputed in a different order can differ in its last
bits (
16038.699999999999vs16038.7), which opens a new stored period and a history row that prints like its neighbour.
[0.3.1.225] - 2026-10-05¶
Fixed - a failed Vector Store collection blocked the server until a manual reset¶
Reported from a deployment: after a documentation + vector-index build that
completed without error, all three Teradata Vector Store collections ended in
update_failed, and the next python -m tdfs4ds.serve start refused them and
asked for a manual reset_teradata_store().
- The finalize pass is verified. After refreshing the collections the build
runs one consolidation update per collection. It is asynchronous, and the
build logged "complete" as soon as the request was accepted, so a finalize
that ended in
update_failedwent unnoticed. The outcome is now checked, and a collection it left failed is recreated. - A failed collection is recreated. It can be neither searched nor updated, so recreating it loses nothing. The build used to stop and ask for a manual reset; it now recreates the collection when the connected user is allowed to (admin), and still asks for the reset otherwise.
The finalize update itself completed normally on the service used for testing, so what made it fail on that deployment is not confirmed.
Fixed - Markdown rendering in the admin app; lineage node documentation¶
- LLM-written text (business dictionary, reports) uses GitHub-style Markdown, which the app's renderer reads more strictly: a list right after a line of text showed as one paragraph with literal dashes, sub-items indented by two spaces were flattened, and numbered questions stored on one line ("1. ...
- ... 3. ...") showed as a single item. These are now adapted before rendering, everywhere the app renders Markdown. Code blocks are not changed.
- Lineage → Inspect node: the documentation was shown in the grey italic "loading" style, so it read like placeholder text and only the SQL stood out. It is now plain text, sections come in a fixed order with readable titles, the overview is no longer shown twice, column descriptions are no longer cut at 120 characters, and a node with no documentation says so (a failed read is reported instead of being silently skipped). The Query optimization process detail had the same grey italic style.
[0.3.1.224] - 2026-10-05¶
Fixed - consumer agent ignored a data domain named in the question¶
Asked "what are the available features contained in the "Governed Demand Forecasting M5" data domain?" on a store with several data domains, the agent answered with a list of datasets presented as features, and its context knowledge graph showed a single domain node, from an earlier turn, with nothing attached. Reproduced on the live store; three faults.
- The named domain was not resolved. The classifier extracts it into its
domainfield, but the domain-detection step resolved a domain only from an object name. The domain is now matched to the stored DATA_DOMAIN (ignoring case and quotes, then a close-spelling match; a theme such as "pharmacy" does not match) and becomes the active domain of the conversation. The graph's domain node follows it. - The summary scanned another domain. It received the domain as a label but read the catalog under the session's DATA_DOMAIN. In the admin app, which runs with none, the catalog filter compared to the string "None" and kept nothing. The summary now reads under the domain it was asked about, and so does the "only one domain, use it" shortcut, which had the same flaw. With no active domain the catalog helper reads every domain.
- Zero features were described anyway. With an empty feature list the LLM was still given every dataset's documentation and listed the datasets. A domain without features now gets a fixed answer saying so.
Live, after the fix: 14 features, 35 graph nodes, no unconnected node; a follow-up on a second domain in the same conversation shows both domains.
Fixed - English question answered in French¶
An English question was answered in French. Every agent LLM call now states the answer language when it is clear from the question (two or more French marker words: French; none: English; otherwise it is left to the model).
[0.3.1.223] - 2026-10-05¶
Fixed - consumer agent asked "which did you mean?" for plain inventory questions¶
Seen in the admin app's chatbot: "what are the available features?" got a numbered "which did you mean?" prompt, and the next question, "what are the data domains?", got one too. Reproduced on the configured model (Mistral Small); there were five faults.
- The classifier prompt asked for low confidence. Its rule said "below 0.5
… when no specific entity is named". An inventory names none by design, so
"what are the available features?", "list the features" and "which processes
exist?" all came back at 0.3 with SUMMARY/SEARCH interpretations. The rule is
gone, and the prompt now says that a question needing no object (an
inventory, a conceptual question) is not ambiguous for lacking one. Measured:
0.3 → 0.95. A deterministic backstop
(
_is_feature_inventory_question, EN/FR) also sends a feature inventory to SUMMARY without a clarification, whatever the model returns. A question with a scope ("…of dataset X", "…for STORE", "…about risk") is not an inventory. - A new question was matched against the pending options. Asked while a
clarification was pending, "what are the data domains?" matched no option and
the same prompt was offered again. A reply that is a new question (more than
three words, ending with "?" or opening like a question;
_is_new_question) now drops the pending choice and is classified on its own. A short reply ("2", "the summary one") still resolves the choice. - The chosen reading was answered as "1". After a choice was resolved, the skill still received the literal reply as its question: a search ran on "1" and answered in French. The resolved turn now carries the chosen option's wording.
- The clarification text drifted in language. It was an LLM call restating a fixed list, and two English questions got French text. It is now a fixed EN/FR template (like the entity clarification), which also saves a model call.
- A search with no data domain selected saw no features and answered that
the store held "exactly 0".
skill_searchnow reads every domain when none is selected.
Fixed - Query optimization panel: no SQL, no scores for most processes¶
get_process_info looked a process up in the session's data domain only. The
panel lists every domain's processes and runs with no domain, so the lookup
failed ("list index out of range") and the detail showed the raw process id,
empty scores and "No stored EXPLAIN documentation". This happened although the
process was scored (4/5, 3/5) and the chart above showed it. A PROCESS_ID is
unique across domains, so the lookup is no longer scoped. This fixes every
get_process_info caller, including documentation and the optimizer.
Verified live on the configured model and store: - the reported two-turn conversation is answered, and all four inventory phrasings get 0.95; - "risk" still asks, in English; "1" answers the chosen reading in English, quoting the 16 registered features; - the panel detail shows the view name, its 4/5 score and its SQL.
Offline: 28 new tests; the suite has 1631 passing.
[0.3.1.222] - 2026-10-05¶
Added - query band per process execution; follow-up ↔ DBQL resource usage¶
Every query of a process execution now runs with a session query band. This
covers the follow-up INSERT, the ingestion, the MERGE and the follow-up
UPDATE. The band carries TDFS4DS_RUN_ID, TDFS4DS_PROCESS_ID,
TDFS4DS_EXEC_SEQ, TDFS4DS_EXECUTION_ID, TDFS4DS_VALIDTIME and
TDFS4DS_VERSION (new module tdfs4ds/utils/query_band.py).
- The caller's band is kept. The pairs are added with
UPDATE FOR SESSION, and the band read beforehand is restored afterwards, also when the execution fails. Setting a pair toNONEwould store the literal "NONE" rather than remove it (verified). - Never fatal. A refused band means the execution runs untagged, with a
warning logged once.
tdfs4ds.QUERY_BAND = False(TDFS4DS_QUERY_BAND) turns it off. followup_resource_usage(process_id=, run_id=, days=30, flush_minutes=10, only_logged=False)returns one row per execution: every follow-up column, then the resource usage of its queries:N_QUERIES,N_ERRORS;- CPU:
AMP_CPU_SEC,MAX_AMP_CPU_SEC,IMPACT_CPU_SEC(busiest AMP × active AMPs),CPU_SKEW_PCT(1 − AMP_CPU / IMPACT_CPU) andPARSER_CPU_SEC; - I/O:
TOTAL_IO,PHYSICAL_IO,IO_SKEW_PCT; - spool:
MAX_SPOOL_BYTES; - DBQL times:
FIRST_QUERY_START,LAST_RESPONSE.
followup_resource_usage_sql() returns the SQL.
create_followup_resource_view() creates FS_V_FOLLOW_UP_RESOURCES (the
follow-up columns plus these) where the view database may read DBQL. The
existing FS_V_FOLLOW_UP is left unchanged, so it keeps working for users
without DBQL access.
- DBQL is flushed from a cache (every 10 minutes by default), so a recent
execution has no DBQL row yet. The join is a left join, and each row carries
DBQL_STATUS: LOGGED, PARTIAL (totals may still grow), PENDING
(nothing written yet) or NOT LOGGED. Pending is judged on the last activity
(end, or start when it never ended), so a row left RUNNING by a crash
months ago reads NOT LOGGED rather than pending forever (seen live). A log
line says when executions are still pending.
- Pairing without a schema change. FS_FOLLOW_UP has no execution id, and
adding one is not possible: its INSERT … SELECT lists no columns, so older
clients sharing the store would fail with Error 3812 (verified). A row is
identified by RUN_ID, PROCESS_ID and its rank by START_DATETIME, which
TDFS4DS_EXEC_SEQ carries. A gap in DBQL therefore does not shift the
pairing. The limits are documented: a failed follow-up insert, and two
executions of the same process starting within the same second.
Added - check_configuration: temporal and query band / DBQL¶
Two checks run first when a database session is open; without one they are skipped with a hint.
- Temporal (VALIDTIME): a session-only VALIDTIME table is created, written,
read
CURRENTandNONSEQUENCED, then dropped. - Query band & DBQL: the band is set, read back and restored. The check then reports whether DBQL is readable and logs this user's queries.
- Only a refused
SET QUERY_BANDfails the check. - Missing DBQL access or logging returns the statement to ask the DBA for
(
GRANT SELECT ON DBC.QryLogV TO …/BEGIN QUERY LOGGING WITH SQL ON …).
Added - benchmark: parse-time check first¶
run_benchmark(max_parse_ms=5000) first times EXPLAIN SELECT * FROM <dataset>
three times: the parse and optimise cost, with nothing executed. It records
the view's DDL size and plan steps. This runs before the key sampling, which
already executes the whole view.
Above the threshold, the run stops and the report explains why, suggesting the
grouped strategy; 0 never stops. The admin Benchmark panel has a Stop if
parse time exceeds field and a Parse-time check skip box, and the CLI has
--max-parse-ms.
Verified live on Vantage 20, 11/11, with no permanent write:
- both new checks work: temporal OK, and DBQL readable but not logging for this
user, reported with the BEGIN QUERY LOGGING command;
- the band is set inside the real ingestion path and restored, even after a
failed execution, and the caller's own pairs are kept;
- the follow-up ↔ DBQL join query executes: 8 real follow-up rows, all
NOT LOGGED, since this user's queries are not logged. With DBQL empty for
this user, the skew arithmetic was checked on literal values instead:
50 % skewed, 0 % even, NULL when no CPU was used;
- the parse gate stops a run and lets one continue. Measured on the same
dataset: multi-join 580 ms (5.9k characters of DDL), grouped 338 ms (3.1k).
Offline: 18 new tests; the suite has 1582 passing.
[0.3.1.221] - 2026-10-02¶
Added - EFS map panel in the admin app¶
A new EFS map panel shows every object of the feature store, for architects and DBAs, split into two planes.
- Metadata plane — the catalogs that describe, govern and drive the data,
in six groups:
- feature catalog;
- process catalog;
- run history and run drivers, including the FilterManager / TimeManager objects named by the process catalog;
- dataset catalog;
- business dictionary and documentation;
- lineage graph.
Each hidden table is paired with its public view, with rows and perm size.
Unknown FS_ objects go to an Other group rather than being dropped.
- Data plane — the FS_T_* storage tables grouped by entity (the key
column set, order-insensitive), between the process views that write them
and the datasets that read them. Each table card shows:
- the number of features and of rows, with a bar relative to the largest table;
- the value type, perm size and AMP skew;
- the primary index and the partitioning expression;
- the companion FS_V_* view.
A table listed in the feature catalog but absent from the database is
flagged TABLE MISSING.
- Row counts come from the table-level statistics by default. Count rows
exactly runs COUNT(*) in CURRENT and NONSEQUENCED VALIDTIME, one
UNION ALL per 25 tables, with a per-table fallback so one unreadable table
does not hide the others. Current and history rows are then shown separately.
- Rows per feature: a bar chart per storage table, one bar per feature and
process version, current and history. A FEATURE_ID that is not in the
catalog for that table is shown in red. Only storage tables of the store are
accepted.
- Export HTML writes the map as one standalone file.
The panel is built by a new module, tdfs4ds/serve/efs_map.py:
- collect: catalogs read once; the dictionary (TablesV, IndicesV,
PartitioningConstraintsV, StatsV, TableSizeV) read in batched IN
lists, reusing feature_store.statistics;
- model: a pure model builder;
- render: an HTML renderer with scoped CSS and inline SVG, no script.
Routes: GET /ui/efs?exact=, GET /ui/efs/table?db=&table=,
GET /ui/efs/download. process_storage_tables accepts catalog frames already
read.
Found while building it: in a UNION ALL, Teradata types an uncast literal
column from the first branch, so the db.table labels of the batched count
were truncated and most counts were lost. The labels are now cast to VARCHAR.
Verified live on Vantage, read-only: - the map: 7 storage tables in 4 entity groups and 32 metadata objects with their counts, in 13 s; - exact counts equal to the statistics; - the rows-per-feature chart in 3 s; - through the HTTP routes, 9/9, including refusing a catalog table as a chart target and the standalone export.
Offline: 12 new tests.
[0.3.1.220] - 2026-10-02¶
Fixed - datasets were missing from the lineage graph¶
build_dataset wrote a dataset's lineage only with record_lineage=True,
which defaulted to False, and rebuild_dataset / build_grouped_dataset
never wrote it. A dataset therefore reached FS_LINEAGE_GRAPH only after a
full rebuild_lineage_graph(). On the test store none of the 7 datasets was in
the graph, although the graph built from each one is correct (14 edges for
ds_m5_diagnostic_features_all, down to its source table).
record_lineagenow defaults toTrue. No LLM is involved, the write is diff-based (an unchanged dataset writes nothing) and a failure is a warning. The internalupload_features/run()call now records it too.rebuild_datasetandbuild_grouped_datasetrecord it.- Lineage panel: registered datasets absent from the graph are named, with
an Add them to the lineage button (
POST /ui/lineage/add-datasets→panels.lineage_add_datasets, which records only those datasets). - The
persisted-graphpage documented anupload_features(record_lineage=)parameter that does not exist; corrected.
Fixed - admin app: Benchmark did not see a newly created dataset¶
Panels are rendered once and kept, so the Benchmark dataset list was read only the first time the panel opened. A grouped dataset created in Maintenance then stayed invisible until the page was reloaded. The list now reloads each time the panel is shown again, and a ↻ Refresh list button reloads it on demand (the selection and the last report are kept).
Changed - grouped conversion confirms the catalog registration¶
Maintenance → Datasets — grouped version now reads the dataset catalog after the conversion and says whether the dataset is registered, with its id and strategy, or points at Dataset catalog → Restore if it is not.
Changed - consumer agent: large listings become an overview¶
A store with 300+ features answered "what features are available?" with every
feature. Above the new tdfs4ds.AGENT_LIST_MAX_ITEMS (default 40; 0
restores the former behaviour) the SUMMARY skill (store, data domain, entity or
dataset scope) answers with:
- the count and the breakdown by entity;
- the main themes;
- at most a few examples per group;
- a fixed EN/FR footer with questions that narrow the request, naming the largest real entities.
Only the examples reach the model, so it cannot enumerate everything; the full list stays in the skill result for the context graph and the follow-up cache. The data-domain list shows the largest domains and says how many more exist.
Verified live on Vantage, 9/9 with scratch objects: the Lineage panel named
ds_m5_diagnostic_features_all as missing; a grouped conversion through the
app reported its registration (strategy GROUPED), recorded its lineage and
appeared in the Benchmark list; Add them to the lineage recorded a
dataset; the real lineage edges and catalog rows were unchanged. Offline: 15 new
tests; full suite 1570 passed, the 6 known vector-store baseline failures
apart.
[0.3.1.219] - 2026-10-02¶
Added - restore the dataset catalog from the admin app¶
Maintenance → Dataset catalog runs restore_dataset_catalog through the
Maintenance live-log route (op=dataset_catalog, restore_mode=check|apply,
drop_orphans=, datasets=):
- Check is the dry run; Restore applies after a confirmation.
- The result table shows, per dataset, the status, how the view is built, what disagrees and the action.
restore_dataset_catalognow logs adataset catalog PROGRESS k/Nline per dataset, which drives the progress bar.
Verified live through panels.rebuild_run, 9/9: a store-wide check found the
6 real datasets needing repair and wrote nothing; a scoped restore of a
corrupted scratch dataset fixed its valid time, ids and build row with the same
DATASET_ID; the real datasets' rows were untouched.
Fixed - cross-store migration dropped a dataset's build options¶
migrate_objects rebuilds each migrated dataset with build_dataset, without
its options, so a grouped, fixed-date or TimeManager dataset arrived in the
target store as a current multi-join one. The strategy, join type and point in
time are now read from the source view's DDL (_dataset_build_options) and
passed through.
A TimeManager dataset is re-pointed to the manager's migrated copy when the
plan placed one. Otherwise it keeps referencing the source store's manager, and
the migration report says so (note), so it can be re-pointed with
rebuild_dataset(..., time_manager=...).
Docs - the dataset catalog schema¶
concepts/data-model.md named a dataset catalog table that does not exist
(FS_DATASET). It now lists the four tables and views
(FS_FS_DATASET_CATALOG, _ENTITY, _FEATURES, _BUILD) with their columns,
checked against DBC.ColumnsV on a live store.
[0.3.1.218] - 2026-10-02¶
Changed - TimeManager datasets keep partition elimination¶
A dataset built with time_manager= used
VALIDTIME AS OF (SELECT BUSINESS_DATE FROM <tm view>) in each feature
sub-query. Reading the EXPLAIN steps (not just their count) showed that, in
the grouped strategy, it makes Teradata read every storage table "by way
of an all-rows scan", where a literal date reads "from 3 partitions of…".
That is a full scan of each storage table on every query.
Each sub-query is now NONSEQUENCED VALIDTIME with the period filtered by
the manager's business date as a scalar:
B1.ValidStart <= (SELECT CAST(BUSINESS_DATE AS TIMESTAMP(0) WITH TIME ZONE) FROM <tm view>) AND B1.ValidEnd > (…).
This is the predicate Teradata itself generates when it rewrites an AS OF,
so the instant selected is the same.
Verified live on a 10-feature dataset, both strategies:
- rows identical to a literal date, for a full read and a single-entity lookup;
- partition elimination in every storage read (primary-index access for the
single entity), no all-rows scan of a storage table;
- the business date read once per query, through the manager's primary index;
- the dataset follows update().
The business date is deliberately not wrapped in MAX(...). That would
raise the optimizer's estimate of the manager view from "2 rows, low
confidence" to "1 row, high confidence", but it changes nothing in the
feature-table plan, because the scalar is substituted as a parameter first.
And a manager view that wrongly returned two rows would then pick one date
silently instead of failing with Error 3669.
DATASET_VALIDTIME now records TIME_MANAGER <db>.<view> for such a
dataset. Views written with the earlier shape still parse as TIME_MANAGER;
rebuild them (rebuild_dataset(view, replace=True)) to get the new filter.
CURRENT and AS_OF datasets are byte-identical.
Added - convert a dataset to its grouped version from the admin app¶
Maintenance → Datasets — grouped version lists the registered datasets with
their strategy, join type and time mode (GET /ui/datasets). It converts one to
the grouped strategy, either in place (same name, same DATASET_ID) or as a
new view (default <name>_GROUPED, optional overwrite), and can preview the
SQL first (POST /ui/datasets/grouped, backed by
panels.dataset_grouped → build_grouped_dataset).
- An in-place request on an already-grouped dataset does nothing and says so.
- A refusal (target exists, not a dataset view) is shown with its reason. The
panel forces
DISPLAY_LOGSon for the call becauselogger_safedrops every message, errors included, while it is off.
Verified live, through the panel functions (7/7) and the HTTP routes (listing, preview, convert, refusal, unknown view).
Fixed - connect() re-created the build table on every call¶
connect() checks a fixed list of catalog object names, and the build
table/view were missing from it. The catalog therefore saw them as absent,
retried their CREATE, hit Error 3803 and logged a misleading
"could not create the optional object" warning. The pair is now on the list,
and an "already exists" during an optional creation is treated as present.
Offline: 1547 passed (7 new in tests/test_datasets_panel.py); the 6
failures are the known vector-store baseline.
[0.3.1.217] - 2026-10-02¶
Added - review, complete and refresh the storage statistics (check_storage_statistics, admin app)¶
Ingestion's normal path refreshes only the statistics a storage table already
has, so the (entity columns, FEATURE_ID) statistic added in 0.3.1.216 never reaches an existing table on its own.
tdfs4ds.check_storage_statistics(domains=, processes=, tables=,
add_missing=False, refresh=False) (new module
tdfs4ds.feature_store.statistics) compares each FS_T_* table's statistics
(DBC.StatsV) with the expected set. It reports OK / MISSING / NO_STATS /
TABLE_MISSING with the missing column sets, statistics defined outside
tdfs4ds (EXTRA, never dropped) and the oldest/newest collection date.
- Default is read-only.
add_missing=Truedefines what is absent with ingestion's own options, so later merges keep it current.refresh=Trueruns the sameCOLLECT STATS ON <table>ingestion runs. - Batched reads. The data dictionary is read in four batched queries
(
TablesV,IndicesV,PartitioningConstraintsV,StatsV), not one per table. - One rule. The expected set comes from the new
expected_statistics(), whichgenerate_collect_statsnow renders from (output unchanged). The check therefore cannot disagree with ingestion. TABLE_MISSINGwas found live: a test cleanup had dropped a storage table but left its feature-catalog rows. The first version reported it asNO_STATSand tried to collect on it (Error 3807). It is now detected viaDBC.TablesVand never acted on.- Admin app. A Storage statistics card in Maintenance (Check, Add missing, Refresh, Add missing + refresh, scoped by data domain and process, with progress streamed to the live log). A read-only Storage statistics section in the Report, included in the exported HTML. The Report's process → storage-table map now delegates to the same package function.
Verified live through the real HTTP routes, 11/11. Covered: store-wide check
(6 MISSING, 1 TABLE_MISSING), add + refresh scoped to one process's two
tables, re-check OK, refresh alone, and the Report section. The added
statistics were dropped afterwards, restoring the tables' original state.
Offline: 1534 passed (12 new in tests/test_storage_statistics.py); the 6
failures are the known vector-store baseline.
[0.3.1.216] - 2026-10-02¶
Added - datasets at a fixed date or following a TimeManager¶
build_dataset reads CURRENT VALIDTIME by default (unchanged). Two new
arguments choose another point in time:
as_of=: a fixed date. ISO strings ('2024-03-31','2024-03-31 23:59','2024-03-31T23:59:59Z','... +0200'),datetime,date,pandas.Timestampandnumpy.datetime64are normalised bytdfs4ds.format_timestamp_literal. A bare date becomes midnight: Teradata rejects a date-onlyTIMESTAMPliteral (Error 3706, verified live). Ambiguous input such as'03/31/2024'is refused, not guessed.time_manager=: the dataset follows aTimeManager(or a'db.view'name). Each feature sub-query readsVALIDTIME AS OF (SELECT BUSINESS_DATE FROM <manager view>), sotm.update(i)moves the dataset without rebuilding it. This shape was chosen by comparing EXPLAINs against two join-based alternatives (NONSEQUENCED VALIDTIME+CROSS JOINwith a range predicate, and aValidPeriod CONTAINSjoin). Its plan matches a literal date (2 extra steps, no product join), while the alternatives doubled the steps and added one product join per sub-query. Verified live forDATE,TIMESTAMP(0)andTIMESTAMP(0) WITH TIME ZONEbusiness dates, including the exact day boundary. Superseded in 0.3.1.218: in the grouped strategy this shape loses partition elimination; the period is now filtered with a scalarWHEREinstead.
The three modes (including the historical feature_store_time=True) are
mutually exclusive. feature_store_time=True now normalises
FEATURE_STORE_TIME the same way, which leaves a well-formed value
byte-identical.
Added - rebuild a dataset from its own DDL: build_grouped_dataset, rebuild_dataset¶
New module tdfs4ds.dataset.ddl_spec reads a dataset view's DDL back into its
specification (parse_dataset_ddl): entity, and per feature the storage
table, FEATURE_ID, process version and column name, plus join type, strategy
and temporal mode. render_dataset_ddl writes it back. Both strategies now
render through this one function: the two builders each carried their own copy
of the template, and they are now thin wrappers. On 24 configurations the
output is byte-identical to before, apart from the HAVING change below.
build_grouped_dataset(view)writes the grouped version of an existing dataset (default target<view>_GROUPED, or in place withreplace=True).rebuild_dataset(view, grouped=, join_type=, as_of=, time_manager=, current=)is the general form. It can also re-time an existing dataset.read_dataset_spec(view)returns the parsed spec.
Features are not re-resolved against the catalog, so the result does not
depend on DATA_DOMAIN or on the versions registered today. An existing
target is refused unless replace=True.
Why grouped matters, measured with EXPLAIN on Vantage 20 (10 features, 3
table/version groups): the view SQL drops from 5,842 to 2,972 characters,
JOINs in the plan from 9 to 2, EXPLAIN (parse + optimise) from 627 to 190 ms,
and a single-entity lookup from ~455 to ~248 ms. The rows are identical
(bidirectional EXCEPT ALL = 0).
Fixed - the grouped strategy dropped entities whose feature value is NULL¶
The grouped INNER form filtered HAVING MAX(CASE ...) IS NOT NULL. The
multi-join keeps an entity whose stored value is NULL (the row exists), so
the two strategies returned different rows despite the docstring promising the
same semantics. It now tests row existence:
HAVING COUNT(CASE WHEN FEATURE_ID=n THEN 1 END) > 0. Verified live on a
scratch temporal table: multi-join {1,2}, old grouped {1}, new grouped
{1,2}.
Added - restore_dataset_catalog(): list every dataset and repair the catalog¶
Earlier versions wrote parts of the dataset catalog wrongly, and nothing read those parts back. Observed on a live store, where every row was affected:
DATASET_VALIDTIME = 'SELECT'(pre-0.3.1.214 registrations);FEATURE_IDstored space-padded (' 6'). The column isVARCHAR, the MERGE passed a bare integer and Teradata padded it on the implicit cast. Now written as a string literal;- (pre-0.3.1.79) a view registered with zero features;
- a view registered twice made every later
add_datasetfail. That path raised a bareraise(aRuntimeError: No active exception); it now raises aRuntimeErrornaming the fix.
restore_dataset_catalog(databases=None, discover=True, dry_run=True,
drop_orphans=False, datasets=None) parses each dataset view and compares it
with the catalog. It returns one row per dataset with STATUS
(OK/REPAIR/REGISTER/DUPLICATE/ORPHAN/UNPARSEABLE), ISSUES and
ACTION. Dry run by default. Repairs keep the DATASET_ID (the MERGE is
keyed on it); a duplicate keeps one id and closes the others; orphans are
closed only with drop_orphans=True. Unregistered dataset views are found
through a DBC.TablesV scan (discover=False disables it).
Added - the build table: how each dataset was built¶
The catalog gains FS_<name>_BUILD / FS_V_<name>_BUILD, read by
tdfs4ds.get_dataset_build(): BUILD_STRATEGY, JOIN_TYPE, TIME_MODE,
AS_OF_TIMESTAMP, TIME_MANAGER_DATABASE/_VIEW/_TABLE, and
SOURCE_DATASET_ID for a rebuilt dataset. It is a separate table, so the
existing catalog tables need no ALTER and older versions read them
unchanged. DATASET_VALIDTIME also records the governing qualifier for every
mode.
It is created by the catalog's self-heal (setup(), connect(), any dataset
call). It is optional: a user without CREATE rights gets one warning per
process, and connect() keeps succeeding as before; the creation is not
retried on every read. create_catalog() now reuses the object sets
connect() already holds instead of listing the database again.
drop_dataset also closes the build row.
Added - statistic on (entity columns, FEATURE_ID)¶
DIAGNOSTIC HELPSTATS on dataset queries asked for it. Without it the
optimizer estimated a single-entity lookup on a multi-join dataset at 7.79 s
instead of 0.02 s (measured live, with planned product joins around the
misestimate). generate_collect_stats now includes it in its column set. That
set runs when a storage table has no statistics yet; afterwards ingestion
refreshes the existing set with COLLECT STATS ON <table>. So a table created
from now on gets it, and an existing one needs it defined once:
tdfs4ds.check_storage_statistics(add_missing=True) (0.3.1.217) or
Maintenance → Storage statistics → Add missing.
Verified¶
Offline: 1522 passed. The 6 failures are the known vector-store baseline. The
new tests are 82 in tests/test_dataset_temporal.py (including a DDL captured
from a live store) plus updated catalog/stats tests. Live on Vantage 20:
31/31 end-to-end checks. Covered: CURRENT, grouped, build_grouped_dataset,
as_of before/after the data, a TimeManager dataset moving with update(),
rebuild_dataset grouped + TimeManager, catalog and build rows for every mode,
unpadded ids, and restore of REPAIR/REGISTER/DUPLICATE/ORPHAN with ids
preserved. A scoped apply left the real datasets' rows untouched. All scratch
objects were removed.
[0.3.1.215] - 2026-10-01¶
Added - renew an expired LLM token mid-run instead of losing the run¶
With a temporary credential -- typically a short-lived Bedrock bearer token
in INSTRUCT_MODEL_API_KEY -- a long rebuild_documentation() or vector
rebuild could outlive the token. The column-documentation call has no
try/except, so the first call after expiry raised out of the documentation walk
and the whole rebuild stopped. The EXPLAIN and dataset-description calls, which
do catch their errors, quietly produced empty documentation for every object
after that point.
Every LLM call site already rebuilds its client from tdfs4ds.INSTRUCT_MODEL_API_KEY
on each call, so once the session holds a new token, retrying the failed call
is enough -- nothing has to be resumed. New module
tdfs4ds.utils.credential_renewal:
is_credential_expired_error(exc)matches AWS's own wording (ExpiredTokenException,UnrecognizedClientException, "the security token included in the request is expired/invalid", the botocore error code, ...), walking__cause__and never__context__. No bare word or number -- the same rule as the 0.3.1.212 reconnect classifier, with negative tests against 400/422/503, context-window and structured-output errors and the teradatasql boilerplate header.call_with_credential_renewal(fn, what=, on_renewal=)runsfn(). On an expiry error it asks for a renewed token (hidden input; notebook kernels supported through the existingai_studioprompt helpers), installs it inINSTRUCT_MODEL_API_KEY, inEMBEDDING_MODEL_API_KEY(only when that is set explicitly) and inAWS_BEARER_TOKEN_BEDROCK, then retries. It allowstdfs4ds.CREDENTIAL_RENEWAL_ATTEMPTSattempts (default 3;0= never ask).fnbuilds its client inside itself, because a client built before the renewal still carries the old key.- A blank answer means stop. The declined key is remembered, so a 300-process rebuild does not ask 300 more times; setting a different key, or starting a new run, re-enables prompting.
- When there is nobody to ask (a server, papermill, a script), nothing is prompted and the original error propagates, as before.
Wired into the four documentation LLM sites (column documentation and its
formula-consistency retry, EXPLAIN analysis, [You]-item classification and the
dataset description), inside their existing try blocks so that renewal
happens before a non-fatal handler can swallow the error. If renewal is
declined, _document_from_graph stops the walk cleanly and returns the partial
summary with stopped_on_credentials=True and a warning that says how to
resume: re-run with force_update=False, which skips everything already
written. rebuild_documentation then also skips the vector-index update.
Changed - vector indexing (Chroma) only embeds rows that changed¶
build_vector_index(force_rebuild=False) used to re-embed every
business-dictionary row on every call. Rows already stored with the same text,
metadata and embedding model are now skipped. Each row records the model that
embedded it in a new embedding_model metadata key, so switching
EMBEDDING_MODEL_MODEL re-embeds everything. The lookup is fail-open: a Chroma
read error re-embeds everything, as before. The first build after upgrading
re-embeds once, because existing rows have no embedding_model key yet. The
log now reports N embedded (new or changed), M skipped (unchanged).
The batch size is configurable through tdfs4ds.EMBEDDING_BATCH_SIZE
(default 8, unchanged), and an expired embedding token is renewed the same way
(the embedding client is rebuilt and the current batch retried). The Teradata
Enterprise Vector Store backend embeds inside the database with its own
credential and is unaffected by either change.
[0.3.1.214] - 2026-09-24¶
Fixed - DATASET_VALIDTIME recorded the 5th line of the DDL¶
Spotted in tdfs4ds.dataset_catalog() on a live store: the column read
SELECT A1.CUSTOMERID.
Dataset._get_validtime was self._get_ddl().split('
')[4].strip() -- the 5th
line of the SHOW VIEW text. The generated DDL begins with a newline (the
builder's f-string opens with one), so that index lands on the SELECT, and
every registered dataset recorded a fragment of its own select list as its valid
time. Nothing in the package reads the column back, which is why it survived:
it is write-only metadata, displayed to the user and to nobody else.
_extract_validtime(ddl) now scans for the qualifier instead of indexing a
line, and handles both shapes dataset/builder.py emits: normally an outer
CURRENT VALIDTIME, and for a time-travel build an empty outer qualifier with
VALIDTIME AS OF TIMESTAMP '...' inside each feature sub-query. The outer one
wins, because it governs the view -- the scan stops at the first SELECT, so the
SEQUENCED VALIDTIME inside the sub-queries is never mistaken for it. Neither
present yields '': an empty valid time is honest, a wrong one is not.
Fixing it exposed a second bug, so both ship together. The catalog MERGE
interpolated the value raw, as '{dataset.valid_time}'. That was harmless only
while the recorded value was a SELECT fragment, which happens to carry no
quotes -- the correct time-travel qualifier contains apostrophes, and would have
closed the literal early and broken the MERGE that registers the dataset. The
value is now escaped with sql_literal, which escapes without wrapping (the
caller supplies the quotes, per its own docstring); a test asserts the rendered
literal whole, having caught this being got backwards during the fix.
Pre-existing rows keep their wrong value until the dataset is registered again
(DatasetCatalog.add_dataset, i.e. any re-run of build_dataset); the MERGE is
keyed on DATASET_ID, so re-registering repairs it in place.
Guarded by tests/test_dataset_validtime.py
(16 offline tests, no DB), including a premise test that line 5 of the generated
DDL really is the SELECT (without it the rest could pass vacuously) and a
source-level guard that both builders keep {outer_validtime} immediately
before SELECT {select_query} -- the relationship the scan depends on.
[0.3.1.213] - 2026-09-24¶
Fixed - the classifier was taught an intent its own schema forbids¶
Reported from the field, on a deployed AgentOps agent and reproduced in a plain Python session: "Which features tell me how much a customer spends?" answered fine, and the follow-up "Could you show me a sample?" died with
RuntimeError: Intent classification failed: 14 validation errors for IntentResult
intent.literal['BUILD_DATASET'] Input should be 'BUILD_DATASET'
[type=literal_error, input_value='DATA_QUERY', input_type=str]
... one per available intent
Two defects, and the first one caused the second.
(1) The prompt advertised what the schema refuses. build_graph drops an
unavailable intent from the descriptions AND from the dynamic Pydantic
Literal, so with MCP off DATA_QUERY and ALGORITHM are rejected values. But
_build_classifier_examples walked the skill catalog unfiltered, so the
prompt still carried all five of ca-data-query's few-shot examples -- including,
literally, "Montre-moi un echantillon de DS_SCORING". The model was shown
"a sample -> DATA_QUERY", answered exactly that, and was rejected for it. The
same leak existed for ALGORITHM, masked only by the deterministic
_looks_like_algorithm_question pre-guard that fires before the classifier.
Examples are now filtered by the intents the classifier may actually return, read
from the model itself -- the authority that does the rejecting -- and each
built-in anchor example carries its intent as data rather than being parsed back
out of its rendered text. available_intents=None keeps every example, so any
existing caller is unchanged. A comment in build_graph claiming the classifier
"physically cannot emit" a gated intent is corrected: it was wrong twice over.
(2) An unusable classification ended the turn. intent_classifier re-raises
as RuntimeError, and nothing caught it, so the user got no answer at all. But
whether the schema is enforced is the endpoint's decision, not ours -- the
same gateway had already logged structured_output: this endpoint does not
enforce the requested response format and fallen back to function_calling, and
the traceback confirms it: the failure surfaces in PydanticToolsParser, i.e.
the enum travelled in a tool definition the gateway relayed without constraining.
On such an endpoint an out-of-vocabulary intent is an expected outcome.
_node_classify_impl now recovers: when the rejected value is an MCP-gated
intent and MCP is off, the turn answers with a fixed EN/FR message saying that
reading the data itself needs MCP and naming what the agent can do (the
existing ALGORITHM_UNAVAILABLE precedent, reached reactively rather than from a
question-shape regex); otherwise it degrades to SEARCH. It re-raises only when
nothing is known well enough to route -- inventing a route would be worse than
the error. An MCP-enabled rejection is deliberately NOT reported as "enable
MCP", since there the intent is available and the fault is real.
Guarded by tests/test_intent_vocabulary.py (23 offline tests, no DB/LLM/network), 21 of which fail against the pre-fix code, built on the verbatim error text from the field, plus a cross-check that every intent named in the rendered prompt is one the model accepts, and a premise test asserting the shipped skill still carries the examples that would leak (without it, the filter tests could pass vacuously).
[0.3.1.212] - 2026-09-24¶
Fixed - every SQL error was treated as a lost connection, and the "recovery" ended the session¶
Reported from the field: connect() died on a store where nothing was wrong,
and every statement afterwards failed with [Error 544] TD2 logon mechanism
requires username and password -- a notebook whose own create_context had
just succeeded. The connection was never lost. tdfs4ds destroyed it, in
response to a nominal [Error 3803] Table 'FS_TIME_MANAGER' already exists.
Three independent defects, each needing its own fix.
(1) The classifier said yes to everything. is_connection_error's marker
list contained "session", and EVERY teradatasql message opens with
[Session <n>] -- so every SQL failure in the package, down to a division by
zero, classified as a dropped connection. A bare "803" compounded it by
matching the error codes 3803 / 3807 / 2803 as a substring. Live since
v0.3.1.78 (e641041), ~130 patch versions, with no test covering it. The
new rule is a veto: a message carrying [Teradata Database] was formatted
from a database response, so the server received the request and replied --
whatever went wrong, the connection was alive, and no positive marker is even
considered. Only then is the text matched against transport phrases, none of
which is a bare number any more (socket, connection reset, broken pipe,
failure sending request, SQLState 08S01/08003...). SQLState 08000 is
deliberately absent: the driver reports its own configuration refusals with
it, and Error 544 is 08000. The asymmetry is intentional -- a missed
reconnect surfaces the real error, which is recoverable; a spurious one is not.
Classification walks __cause__ only, never __context__ (an unrelated
exception in flight would otherwise contaminate the verdict), which is the one
place it differs from _describe_exception.
(2) reconnect_teradata destroyed a working context on its way to a broken
one. It called tdml.remove_context() first, and create_context assigns
teradataml's global engine before testing the connection -- so a failed
reconnect left get_context() returning a poisoned engine, and every later
statement in the process failed with that logon error instead of its own. The
session could not self-heal; that is how one "already exists" ended a notebook.
Credentials are now probed with the raw driver first (_credentials_probe,
the jwt_logon_verdict constraint: teradataml holds ONE process-wide context,
so a check must not be able to end a session), and a refusal leaves the
existing context untouched. Only a refusal blocks -- an unreachable host is
inconclusive and still reconnects, since that is the case the feature exists
for. No extra failed logon: on a refusal the probe replaces the
create_context that used to fail. If create_context does fail, the broken
engine is now cleared.
(3) A token and a password mechanism were combined into a credential set that
cannot work. _connect_kwargs preferred TDFS4DS_TD_LOGDATA
unconditionally, so a .env holding LOGMECH=TD2 and a leftover LOGDATA
sent host+username+TD2 and no password -- refused by the driver before it
reached the network, while the same file's USERNAME+PASSWORD would have
worked. When LOGMECH names a password mechanism (TD2/LDAP) and LOGDATA
demonstrably carries no password=, the working pair now wins, with a warning
naming both variables. TDNEGO/KRB5 are excluded (they can complete without
a password) and a LOGDATA that carries its own credentials is left alone, so
every existing token and password deployment produces byte-identical
kwargs.
Not fixed by 0.3.1.211. That release quieted the log line, but
create_object_if_missing still calls execute_query(..., raise_error=True)
and the exception still passed through the misclassifier before the caller's
is_already_exists_error ever saw it.
Guarded by tests/test_connection_error_classification.py
(48 offline tests, no DB/LLM/network), 31 of which fail against the pre-fix
code, including the incident end to end through execute_query /
create_object_if_missing and source-level guards (no marker may be a bare
number; the boilerplate header every Teradata message carries must match
nothing; the probe must use raw teradatasql, asserted on the parsed code
since the docstring names create_context to explain why it is not used).
Offline evidence only -- not yet re-confirmed live.
[0.3.1.211] - 2026-09-23¶
Fixed - a fully provisioned store reported itself as broken¶
Reported from the field: dozens of ERROR lines per run on a store where
nothing was wrong, making the logs useless for supervision -- one cannot watch
for ERROR to detect a real problem when the nominal case emits a dozen of
them. Measured on Vantage 20 before the fix: 13 ERROR lines for a setup()
on an already-provisioned store, 1 for every connect(). After: 0 and 0.
The mechanism was not where it looked. execute_query defaults to
raise_error=False: on a failure it logs the error itself and returns
normally. So the except wrapped around every catalog CREATE never ran --
the endswith('already exists.') test, the if_exists == 'replace' branch and
time_manager_catalog_creation's careful logger_safe('debug', ...) were all
dead code. Execution fell through to logger_safe('info', '... has been
created'), so each table also produced a second, contradictory log line
claiming a creation that never happened. A fix applied to those except blocks
would have changed nothing.
A second site printed the whole driver message. documentation_tables_creation
interpolated the entire multi-line exception into a logger_safe('error', ...)
placed before its already-exists test -- six full Teradata error blocks per
setup(). The fix is not to truncate to the first line (the half that names
the fault is often the later half): the benign case simply stops being logged as
an error, and a genuine failure keeps its full message.
The dangerous part was not the noise. setup(if_exists='fail') -- the
default -- calls process_store_catalog_creation() and
feature_store_catalog_creation() with no argument, hence with their default
if_exists='replace'. Their DROP TABLE branches were unreachable only because
the exception was being swallowed. Making the failure visible without touching
that default would have made every setup() on an existing store drop
FS_PROCESS_CATALOG and FS_FEATURE_CATALOG. Both defaults are therefore now
'fail', which preserves the observable behaviour exactly (setup() still does
its own explicit dropping when asked). An offline test asserts no DROP is ever
emitted, and the live run verifies the catalogs' DBC.TablesV.CreateTimeStamp
is unchanged across a second setup().
The shared mechanism. utils/query_management.py gains
is_already_exists_error(exc) -- the single authority for "the object is
already there", which for an idempotent CREATE is the nominal outcome, not an
error -- and create_object_if_missing(create_query, label, follow_up=()),
which reports honestly what happened and raises (ERROR, full message) only
on a real failure. execute_query gains quiet_errors=(); the empty default
leaves all 58 existing call sites byte-identical, per the package's opt-in
convention. Routed through it: the four process-store catalog tables and their
secondary index, FS_TIME_MANAGER, the feature catalog and its index, the
follow-up table (which had been printing the first error line and the entire
CREATE statement to stdout via @execute_query_wrapper), the six documentation
tables, and lineage_graph_catalog_creation -- already correct, and the model
the rest now follows.
Live-verified on Vantage 20 against a throwaway database, one session (a freshly created database can transiently read as absent in the next one). The run also corrected an assumption: a secondary index reports this condition as Error 3534, "Another index already exists, using the same columns and the same ordering" -- a different code and different wording from a table's 3803, which is why the predicate matches the phrase rather than the code. Guarded by tests/test_create_if_missing.py (7 offline tests, no DB), four of which fail against the pre-fix code.
[0.3.1.210] - 2026-09-22¶
Fixed - a failed ingestion looked exactly like a clean run¶
Reported from the field: a process failed with [Error 2646] No more spool
space, and the orchestrator driving the waves saw 5 OK, 0 failure(s). The
follow-up row stayed RUNNING, later levels computed on features that were
never written, and those levels recorded COMPLETED -- so the wrong values were
then skipped on every subsequent run. Three independent defects, each of which
had to be fixed separately.
1. The per-filter loop had no failure channel. When a FilterManager
segments a process, each filter ran in a protected block that logged the error
and moved on. No counter, no exception, nothing in the return value;
something_computed flipped to True on the first success, so a process with
nine failed filters out of ten was indistinguishable from a clean one.
Two things made this worse than it looks. An unsegmented process (no
FilterManager) propagates its error normally, so the same failure was loud or
silent depending only on whether a FilterManager was attached. And the parallel
execution script that tdfs4ds generates itself detects failures by catching an
exception from run() -- so the package's own scheduler was exactly as blind as
any user's orchestrator.
2. FAILED could never be written -- and this was never a spool problem.
followup_close built the UPDATE and then executed a bare raise before
returning it. Its decorator calls the function to obtain the query and only then
executes it, so the exception escaped first and the statement was discarded.
Callers wrapped the call in except Exception: pass to absorb that raise, which
hid it completely. The effect: no installation has been able to write a
FAILED status since commit bbc3746 (2024-12-04). Anyone observing that
STATUS only ever held RUNNING and COMPLETED was seeing a product defect,
not something in their environment.
This also silently disabled every feature built on it -- _freshness_segments
and the consumer agent's has_recent_failure both look for a FAIL prefix that
could not occur.
followup_close no longer raises, no longer uses @execute_query_wrapper (that
decorator reports an execution failure with a bare print, and this particular
write happens precisely when something has already gone wrong), escapes the
status through sql_literal -- a Teradata message quoting an object name would
otherwise produce malformed SQL -- and truncates it to the column width. Its
WHERE now also pins VALIDTIME_DATE: because RUN_ID is generated once per
import rather than once per run, a success at one time step could otherwise close
the stale RUNNING row left by a failed earlier step, marking it COMPLETED.
3. An empty ingestion was recorded as a permanent success. A process reading
a failed upstream's view does not fail: it ingests zero rows, succeeds, and
writes COMPLETED. Since the skip test is an exact match on that value, the
empty result was then treated as final. An ingestion that stages no rows is now
recorded as COMPLETED,0 rows, which is neither a success the skip test
recognises nor a failure (an empty partition is legitimate) -- so it is simply
recomputed next time, with no manual cleanup. The cost, accepted deliberately: a
partition that stays empty is recalculated on every pass.
Added - on_failure, and an optional upstream guard¶
run, upload_features, roll_out and _upload_features take on_failure
('continue' | 'stop' | 'raise'); raise_on_failure=True is accepted as a
shorthand, and if both are given the explicit on_failure wins with a warning.
The default stays 'continue', so control flow is unchanged for existing
callers -- what changes by default is that failures are now recorded.
'raise' attempts every filter first, so the trace in the follow-up table is
complete, then raises FeatureIngestionError, which carries process_id,
n_failed, n_total and failures so an orchestrator never has to parse a
message.
check_upstream=True on run()/roll_out refuses to compute when an
upstream process has failed at this valid time. The test is "no upstream
failed", not "every upstream completed": upstreams legitimately run on different
cadences, and a guard that cries wolf gets switched off. When it blocks, nothing
is computed, no follow-up row is written, and the message names the failing
processes and both ways forward.
Because the guard reads the persisted lineage graph, it distinguishes three
situations instead of conflating them: the graph is unavailable, the process is
absent from it (typically registered after the last refresh), or the process is
known -- in which case having no upstream is a real answer. The first two do not
block, but they report verified=False and print the rebuild_lineage_graph(...)
command that fixes them. Reporting "nothing to check" would read as an all-clear
from a check that never ran.
generate_execution_script gained the wave-level wave_on_failure
('skip_descendants' by default, plus 'continue', 'stop_after_wave',
'raise'). 'skip_descendants' skips only the failed process's downstream
closure and lets unrelated branches finish. The generated worker now runs each
process with on_failure='raise', since a wave policy can only react to
failures it is told about.
The wave setting is named apart from the per-process on_failure on purpose.
The two value sets overlap without agreeing -- 'raise' is valid at both levels
and means something different, 'stop' exists only per process, and
'skip_descendants'/'stop_after_wave' only per wave -- so sharing one name
would make a value that is invalid at one level read as a documentation error.
Known limitation¶
Re-running a fixed process does not repair downstream processes that already
recorded a genuine COMPLETED while computing on empty inputs. Those are skipped
on the next pass and need force_compute=True, or their follow-up rows removed.
47 offline tests cover this (tests/test_failure_reporting.py, tests/test_upstream_check.py); no database, LLM or network required.
Live-verified on Vantage 20. A process segmented into three partitions -- one
raising a Teradata error, one with no rows, one nominal -- recorded exactly
FAILED,[...] [Error 2619] Division by zero ..., COMPLETED,0 rows and
COMPLETED. With the default policy run() returned normally while leaving all
three rows behind; with on_failure='raise' all three partitions were still
attempted (so the trace stays complete) before FeatureIngestionError reported
"1 of 3 attempted". A status carrying apostrophes round-tripped intact. A
second process reading the first was then blocked by check_upstream=True,
which returned without computing and without writing any follow-up row,
while check_upstream=False ran it to COMPLETED.
[0.3.1.209] - 2026-09-22¶
Fixed - a database name with a special character produced invalid SQL everywhere¶
A Teradata object name is only legal unquoted when it matches
[A-Za-z][A-Za-z0-9_$#]*. Anything else -- an @, a hyphen, a space, a dot, an
accent -- has to be written as a double-quoted identifier. This matters because
tdfs4ds.SCHEMA is routinely the logon user's own database (__init__.py
defaults it to _get_current_databasename()), and an LDAP/AD login carries any
of those. For such a user setup() failed on its first DDL statement and
nothing downstream worked: roughly 210 sites interpolated the name raw, as in
f"CREATE MULTISET TABLE {tdfs4ds.SCHEMA}.{...}".
Quoting is conditional, so an ordinary name is untouched. quote_ident('TDB')
returns TDB. Verified by capturing every statement the store-creation path
emits and diffing it against the same capture taken before the change: byte-identical
for a plain name, fully quoted for SVC@CORP. Both are pinned by
tests/test_quoted_identifiers.py.
Quoting applies to identifier slots only -- never to a credential, a SQL
string literal (WHERE DatabaseName = '...' against DBC compares a string; a
quoted name there reads as a column reference), a teradataml schema_name=
kwarg (those quote themselves, or need a literal), or a value persisted into a
catalog column. tdfs4ds.SCHEMA and view_schema() therefore keep returning the
RAW name; the new tdfs4ds.schema_sql() / view_schema_sql() return the
identifier form.
New tdfs4ds/utils/identifiers.py is the single authority: needs_quoting,
quote_ident(name, force=False), unquote_ident, qualified(db, obj),
split_qualified, sql_literal. It replaces five partial implementations
(lineage/network._quote_ident, agent/query_optimizer._q, and three copies of
_get_database_username) and three competing name splitters.
The existing dot-only workaround was half a fix. _get_database_username
quoted a logon name when it contained a dot -- necessary, but the package then
re-split the result naively: for "DEINF.CARLOSN"."temp_x",
_table_name.split('.')[1] yields CARLOSN, not temp_x. split_qualified is
quote-aware, so a dot inside a quoted part no longer splits the name. The DDL
scanners in lineage/lineage.py and utils/lineage.py had identifier classes of
[\w".], which cannot match an @; those now accept @$#. A hyphen is
deliberately not added there -- it is the SQL minus operator and starts a
-- comment, so it cannot be matched unambiguously; setup() logs this gap
when it sees a name that needs quoting.
Live-verified on Vantage 20 against a real "FS@TEST" database: setup(),
upload_features, run(), build_dataset (values correct), TimeManager,
FilterManager, segmented ingestion, rebuild_lineage_graph (edges resolved
through the quoted database) and the catalog reads. The identical scenario on a
plain-named database behaves identically, including a pre-existing Error 3806 in
the filtered follow-up close that is unrelated to quoting.
[0.3.1.208] - 2026-09-11¶
Fixed - 34 of 35 SKILL.md files were not valid YAML¶
Reported from an editor's Markdown preview:
Failed to parse frontmatter
Nested mappings are not allowed in compact mappings at line 1, column 7:
name: Feature Store: Deploy to AgentOps
Three faults, all present since the files were written: an unquoted : inside
a scalar (name, description), an argument-hint beginning with [ — which
YAML reads as a flow sequence, so a consumer expecting text silently got a
list or an error — and values carrying double quotes.
Nothing noticed because tdfs4ds parsed its own files with a parser nobody else
uses. cli._read_frontmatter split on the first colon and stripped
surrounding quotes, so it read every one of these happily. Anything loading them
the way they are meant to be loaded — an editor preview, an agent framework, any
YAML library — failed. These files exist to be read by other tools; agreeing
with those tools is the point.
- Every frontmatter value is now a quoted YAML scalar. The rewrite asserted, per file, that no value changed meaning.
_read_frontmatteruses YAML first, keeping the permissive split as a fallback for an environment without PyYAML (a transitive dependency, not a declared one) and for a file that is malformed anyway — where returning what can be read beats returning nothing, since the description decides whether a skill is offered at all.- A parametrised test now asserts, for every shipped skill, that the
frontmatter is valid YAML, that every value is a string, and that
_read_frontmatteragrees withyaml.safe_loadexactly.
[0.3.1.207] - 2026-09-11¶
Fixed - skills carrying claims this session disproved¶
A review of the skills touched by the AI Studio work found one wrong statement and two gaps.
fs-deploy-agentops named the wrong token. It described auth='jwt' as
"the platform authenticates with a token (AI Studio %gen_token)" — conflating
the two tokens whose difference cost most of a debugging session. The pod never
sees %gen_token's token and cannot call it; what reaches it is the session/SSO
token, which on at least one site is refused by the database. The table row
no longer names %gen_token, and a note states the distinction and its
consequence. The pre-deploy verification snippet also moved from
tdml.create_context to raw teradatasql — teradataml holds one process-wide
context, so the recommended test would have replaced the working connection in
the reader's own notebook.
fs-setup never mentioned the configuration surface. It is the first call
of every session and sent readers to hand-write a .env. It now covers
generate_ai_studio_env() (derive, ask, check, then write — and pass
%gen_token's token where available), export_config for everywhere else, and
check_configuration() as the step that runs before anything is built,
since it needs no feature store.
fs-vector-store documented backends but not authentication — the failure
mode that dominated this session. It now carries the four mechanisms and their
order, plus the four traps: the JWT path needs USERNAME when there is no
active session; a ready-made JWT is stored unvalidated, so an expired one passes
setup and fails later; SQLState 28000 is a refused credential, never "service
down"; and the service version floor is invisible to any read-only check because
an old service only misbehaves on write.
[0.3.1.206] - 2026-09-11¶
Fixed - regenerating the .env silently dropped what a probe had discovered¶
Observed live: a run recorded STRUCTURED_OUTPUT_METHOD='function_calling' —
the value check_configuration(pin=True) had just established by probing what
the endpoint really enforces — and a later generate_ai_studio_env() rewrote the
file with the commented placeholder again. The next check reported the
documentation path unconstrained, with nothing saying the setting had been
dropped, and every new process went back to paying a failed request per call.
The generator derives what the platform publishes and asks for the rest.
STRUCTURED_OUTPUT_METHOD and INSTRUCT_MODEL_CA_BUNDLE are neither: they
are discovered — one by probing the endpoint, the other by somebody working
out which CA signs it. Regenerating now carries them forward from the file being
replaced, marked as such in a comment.
Order is override → previous file → commented slot. A fresh file is unchanged (the slot documents the option without turning it on), and a placeholder left in an old file is not a value.
[0.3.1.205] - 2026-09-11¶
Added - skill: deploy a CUSTOM LangChain agent to AgentOps¶
td-agentops-custom-agent covers hosting an agent of your own on AI Studio,
wired to a Vantage connection, the Enterprise Vector Store and the MCP server —
where fs-deploy-agentops covers the tdfs4ds consumer agent, which tdfs4ds
generates and deploys in one call.
It is written around the failures that do not say what is wrong, each one established against a live deployment:
- the entrypoint must be a bare coroutine —
is_asyncisiscoroutinefunction(_entrypoint)and decorators apply bottom-up, so a tracing decorator between@app.entrypointandasync defmakes the platform drive an async agent synchronously; - the pod has no DNS for the database —
TD_HOSTmust be the IP, and aTDML_2000in ~90 ms is a name that did not resolve, not a timeout; - two AI Studio tokens, only one of them a database credential, which is why
the MCP gateway's refusal is the database's
Error 8017and not an HTTP 401 — and whyauth='jwt'must be settled from a notebook before deploying, since a pod cannot call%gen_token; - 132 MCP tools overflow the context window before the question is read;
runtime_configis not a vault — it renders in clear in the deployment UI; its guarantee is "not committed to source control";- the Vector Store JWT path needs a username without an active context, and
SQLState 28000means a refused credential, never "service down"; thread_idmust never fall back to a constant, or every caller of the service shares one conversation.
Plus a deploy checklist and a symptom table, both drawn from real failures.
Registered in the skills catalogue, which was also missing fs-deploy-agentops.
[0.3.1.204] - 2026-09-11¶
Fixed - five consumer-agent calls asked for JSON on the unconstrained path¶
An audit of every JSON-expecting LLM call in the three subsystems that need typed replies:
| subsystem | seam | covered by the probes |
|---|---|---|
| query optimizer | with_structured_output at 7 sites; its 3 raw invokes return prose or SQL |
yes |
| documentation | _build_provider_llm_caller at 3 sites |
yes |
| consumer agent | with_structured_output for intent classification and EXPLAIN… |
yes |
| consumer agent | …and _llm_call — an unconstrained prose call — at 5 more |
no |
Those five asked for JSON, then hand-stripped markdown fences and ran
json.loads. Two were written as:
so a model answering in prose produced an empty result with no error and no
warning — one of them on the SEARCH path, the main search skill. Measured on a
live LiteLLM gateway, eight of nine constraint syntaxes were accepted and
ignored, and the model answered **Classification:** ... to a request for JSON.
These calls also prepend the grounding and style prefixes, which push towards
prose. Neither STRUCTURED_OUTPUT_METHOD nor check_configuration reached
them.
All five now go through _llm_call_json(...), which:
- sends the prompt through the provider dispatcher — so it honours
STRUCTURED_OUTPUT_METHOD, tool calling included (0.3.1.203), and keeps the one-shot unconstrained fallback; - declares the reply schema at each site, so on an endpoint that enforces anything, this is what gets enforced;
- parses with
_robust_json_parserrather than a local regex; - returns the caller's own
defaulton failure and logs what came back, namingcheck_configuration()andSTRUCTURED_OUTPUT_METHOD='function_calling'. The silent empty is the defect; it must not be reintroduced.
A source-level guard test fails if any call site goes back to parsing JSON out
of _llm_call. The failure is structural — each site looked plausible on its
own, and only the pattern across five of them was wrong.
Fixed - capability checks no longer act as though a feature store exists¶
Proving that an endpoint does tool calling has nothing to do with a feature
store, and settling it before creating one is the sensible order. But the MCP
check's tool guard called _get_forbidden_objects, which read three catalogs
regardless — so with SCHEMA unset it logged TDML_2008 Invalid type(s) ...
'schema_name' twice and the schema name is not defined at ERROR, then
passed anyway. A report that succeeded looked broken, and implied a prerequisite
that was not real.
With no SCHEMA there are no process views, feature tables or datasets to
protect, so "nothing is forbidden" is the correct answer, not a degraded one. It
is now returned directly. With a store configured the guard reads the catalogs
exactly as before — both halves tested.
[0.3.1.203] - 2026-09-11¶
Fixed - documentation could not use the one syntax its gateway enforces¶
The nine-variant sweep, run live against a LiteLLM gateway, returned a result worth stating plainly:
| syntax | verdict |
|---|---|
response_format json_schema (and strict) |
accepted, ignored |
response_format json_object |
accepted, ignored |
guided_json, nvext.guided_json, grammar, format, extra_body.response_format |
accepted, ignored |
| tool calling | ENFORCED |
One of nine. And the two structured-output systems were not equally placed to
use it: the agent could switch (probe_structured_output finds
function_calling, STRUCTURED_OUTPUT_METHOD pins it), while the
documentation dispatcher had only four mechanisms — guided_json,
response_format, format, none — none of them tool calling. So on the
one endpoint offering real enforcement, documentation ran prompt-guided, and the
.env recorded a setting that fixed half the package.
STRUCTURED_OUTPUT_METHOD now reaches the documentation path too:
'function_calling'→ the schema travels as a tool definition withtool_choice, for every provider. The provider names a vendor; this names the mechanism the endpoint in front of it honours, and those are different questions.'json_mode'→response_format={"type":"json_object"}.- unset, or anything unrecognised → the provider mapping, unchanged byte for byte. That is the overwhelming majority of installs, and a typo must not silently drop the constraint.
Tool calling returns the structure as arguments, not as content, and every
caller of the dispatcher reads .content. _content_from_tool_call moves them
across so the contract — "an AIMessage whose content is the JSON" — is identical
for all mechanisms and no call site needs to know which ran. A reply carrying no
tool call is returned unchanged: an endpoint that ignored tool_choice and
answered in prose is the degradation the parser and check_configuration
already handle, and inventing content would hide it.
A side effect worth having: on such a gateway the documentation probe stops
reporting trouble, so the nine-call sweep no longer fires on every
check_configuration().
Added - tool calling gets its own line in the report¶
Two unrelated features rest on tool calling and neither announces it:
STRUCTURED_OUTPUT_METHOD='function_calling' carries the structure as tool-call
arguments, and every MCP skill binds real tools. Where a gateway relays response
formats without enforcing them, tool calling is often the only syntax honoured —
so it stops being a detail and becomes the question the configuration turns on.
The JSON constraints row now states it outright: supported and enforced,
rejected (in which case function_calling and every MCP skill are
unavailable), accepted-but-not-a-tool-call, or nothing at all when nothing
measured it — an unprobed capability is never reported as working or broken.
[0.3.1.202] - 2026-09-11¶
Added - a token's expiry is read and reported, before anything spends it¶
A configuration that had passed every check two and a half hours earlier came back with two failures:
Vector Store service -> [SQLState 28000] The UserId, Password or Account is invalid.
MCP server -> [Error 8017] The UserId, Password or Account is invalid.
Both messages name a password. Neither configuration sends one. The token had simply expired — a ~2h lifetime, used at 2h36m — and every component that still worked was one that authenticates with an API key, which does not expire, so the report looked like two unrelated credential faults rather than one clock.
The Vector Store auth row was the worst of it: still green, because
teradataml stores a ready-made JWT without validating it (its validation
call is commented out upstream), so "configured" never meant "valid".
The expiry is a claim inside the token. Reading it costs nothing — no network,
no service — so the new tdfs4ds/utils/tokens.py does:
token_expiry(token)→('expired'|'expiring'|'valid'|'unknown', detail)with a human interval ("the token EXPIRED 37 minutes ago").jwt_claims(token)— the decoder, now shared withai_studio, which had its own. Two decoders would eventually disagree about what counts as readable.bearer_from_headers(headers)— recovers the bearer from a resolved header mapping so the MCP check can report on what it is about to send.
'unknown' is not a verdict: an opaque token can be perfectly good, and a
malformed one is a different fault. It is never rendered as "expired".
Wired into two checks:
- Vector Store auth now reports the remaining lifetime, and fails on an expired token instead of passing it to the next check to fail obscurely.
- MCP server reports it in the detail line, and when stage 1 fails with an expired bearer the hint leads with that instead of opening on the username and password that were never sent.
The MCP check deliberately still attempts the connection with a token that
looks expired. exp is absolute time, so a skewed clock would otherwise make it
refuse to try a token that works — and if an "expired" token does answer, that
is worth seeing rather than having suppressed.
The signature is never verified. This is a caller reading a token it already holds in order to report on it, not authentication; the issuer validates it for real at the other end. Neither function raises, and no detail string ever contains any part of the token — both asserted by tests.
[0.3.1.201] - 2026-09-11¶
Confirmed - one token serves all three components¶
The first full green run on AI Studio settles what 0.3.1.199 could only
hypothesise. With generate_ai_studio_env('.env', token=user_token):
| use | OAUTH_ACCESS_TOKEN |
%gen_token → user_token |
|---|---|---|
database logon (TD_LOGDATA) |
refused — Error 8017 / SQLState 28000 |
accepted |
| Vector Store | — | accepted (132 collections listed) |
| MCP gateway bearer | 8017 from the gateway's own logon | accepted — 132 tools, and a tool call answered |
The two are different strings. So token= feeding all three is not a
convenience, it is the correct shape: the gateway opens a Teradata session with
the bearer, which makes "is this a database credential?" the single question
behind all three.
Fixed - the run said using OAUTH_ACCESS_TOKEN while using something else¶
A hardcoded message survived the addition of token= and named a source that
was no longer being used. A report that is confidently wrong about where a
credential came from is worse than one that says nothing — this one would have
sent the next reader hunting the wrong token. It now names the real source.
Fixed - choosing the TLS route and then not checking the certificate¶
llm='ingress' selects https, and TD_SSL_VERIFY=false was inherited onto it,
producing INSTRUCT_MODEL_VERIFY_SSL=false — encryption without
authentication, which is the half that does not protect an API key: anyone
able to intercept can present a certificate of their own. It also logged a
warning on every single call.
TD_SSL_VERIFY is a platform-wide hint, in practice about the in-cluster
services and their own certificates. The ingress was observed serving a
perfectly ordinary certificate — Teradata Corporation, issued by Entrust
Limited, 56 days to expiry — with verification switched off by inheritance.
So the generator now asks the endpoint instead of copying the hint.
_tls_verifies(url) performs a handshake against the system trust store —
sending no credential, asserted by a test — and returns True / False /
None for could not tell:
- verifies →
INSTRUCT_MODEL_VERIFY_SSLis left commented out, verification stays ON, and a line says the platform hint was not copied onto this route. - does not verify → written as
falseso the endpoint stays usable, withINSTRUCT_MODEL_CA_BUNDLEnamed as the better fix. - could not tell → the platform hint, exactly as before. "Could not tell" is never read as "it is fine".
Only on https://; a plaintext URL has no certificate and is not probed.
Known - configuration-time noise from the MCP check¶
With no feature store configured yet (SCHEMA unset, as it is while generating
a .env), the MCP check's tool guard logs _all_processes_cached failed,
the schema name is not defined and a bare TDML_2008. Harmless — the check
passes and the guard is fail-open — but it reads like a fault in a green report.
Not addressed here.
[0.3.1.200] - 2026-09-11¶
Fixed - three faults one live AI Studio run showed at once¶
A single generate_ai_studio_env() run on AI Studio, with auth='jwt', produced
a report with three failed checks and one silently wrong setting. They had three
different causes.
The Vector Store token path could not supply a username¶
teradataml.set_auth_token() ... username should be passed in the parameters
or call create_context() before using set_auth_token()
teradataml derives the username from the active database session when it is
omitted, and raises when there is no session yet. resolve_auth_mechanism's PAT
branch already passed username when it was set; the JWT branch never did —
so the one mechanism with no way to supply a username was the one most likely to
be asked without a context, since check_configuration() is designed to run
before connect().
Two halves, both needed: the JWT branch now forwards VECTOR_STORE_AUTH_USERNAME
when it is set (optional, so an install relying on the active session is
unchanged), and generate_ai_studio_env(auth='jwt') now writes that variable
instead of emitting it commented out. The Vector Store service check failed
only because the auth check did, so both rows go green together.
The LLM route was chosen silently, and the platform's only answer is plaintext¶
The generated file pointed at http://litellm...:4000 — the API key and every
prompt in clear — because llm defaulted to 'incluster' and was never asked.
The TLS route (https://{JH_FQDN}/one-td/litellm, the same one-td pattern as
the MCP gateway) was written commented out, where nobody reads it.
llm=None is now the default and asks, exactly like auth: which route the
traffic takes is not derivable — the platform publishes exactly one address and
it is the plaintext one, so deriving means always choosing http:// and never
saying so. The cost of that choice is invisible in the generated file, which
shows only a URL. Without a terminal it still resolves to 'incluster', so
scripts are unchanged.
A token the database refuses was written, and nothing checked it¶
auth='jwt' fans one token into three credentials: the database logon
(TD_LOGDATA), the Vector Store, and the MCP bearer. On a site where the
platform token is not database-capable — confirmed live — all three fail, and
check_configuration cannot see it: the database connection is deliberately
outside its remit.
So the MCP row's Error 8017 was the second symptom of a file whose database
credential was already known not to work.
- New
token=parameter — the database-capable token, feeding all three.mcp_token=stays as the narrower override for a site where the gateway and the database want different tokens. - Under
auth='jwt'the logon is attempted before the file is written, with the same raw-teradatasqlprobe the AgentOps preflight uses. A refusal blocks the write and prints the fix; an inconclusive result (unreachable host, no driver) is never rendered as a refusal.
Changed - one definition of "is this token a database credential?"¶
_jwt_logon_verdict moved from agentops/preflight.py to
tdfs4ds.utils.reconnect.jwt_logon_verdict(), the module that already owns
the TDFS4DS_TD_* → connection mapping. preflight now delegates. Two callers
now ask the question and two copies would drift — one of them silently, which is
the exact failure mode the probe exists to catch. Its two source-inspecting
guards follow the implementation rather than the file it used to live in, and
compare parsed code rather than raw source, since the docstrings legitimately
name create_context and OAUTH_ACCESS_TOKEN to explain why neither is used.
Note¶
A local named token already held OAUTH_ACCESS_TOKEN inside the generator, so
adding a token= parameter would have let the local discard it — accepted and
ignored, precisely how mcp_token= broke in 0.3.1.190. It is renamed
session_token, with a test that compares the two.
[0.3.1.199] - 2026-09-11¶
Added - mcp_token=: the gateway needs a DATABASE credential, not just a valid bearer¶
AI Studio hands out two different tokens, and only one of them logs on to Teradata:
| token | gateway accepts it | database accepts it |
|---|---|---|
OAUTH_ACCESS_TOKEN (session SSO) |
yes | no — Error 8017, proven directly |
%gen_token → user_token |
not yet measured | yes — confirmed live 2026-09-11, and it is a different string from the session token |
The Enterprise gateway opens a Teradata session with the bearer it is given,
which is why a failure there surfaces as the database's Error 8017 rather than
an HTTP 401. So the bearer has to be a database credential — a stricter
requirement than being a valid token, and one the session token does not meet on
every site.
OAUTH_ACCESS_TOKEN remains the fallback — it is the right answer where the
platform token is a database credential — followed by MCP_TOKEN from the
environment and then the device flow. The generated file now explains which
token the gateway needs and what a refusal looks like, so a reader hitting 8017
can tell from the file alone that it is a database refusal and not a bad bearer.
Fixed - mcp_token= was silently discarded¶
An mcp_token = None initialiser predating the parameter reset it before the
resolution order ran, so the argument was accepted and ignored. Caught by its
own test.
[0.3.1.198] - 2026-09-11¶
Added - the preflight verifies the condition auth='jwt' rests on¶
Confirmed live, and it settles two open questions at once: the platform's
session token is refused by the database as a JWT logon —
Error 8017, SQLState 28000 from
create_context(logmech='JWT', logdata='token=...').
So in this environment auth='jwt' is not deployable, exactly as
log_insights warned it might not be, and it also explains the MCP failure that
had been open for several rounds: the gateway accepts the bearer, then attempts
its own database logon on the caller's behalf and meets the same refusal. That
is a Vantage-side trust setting for the token's issuer, not anything tdfs4ds
configures.
The preflight checked only that TDFS4DS_TD_HOST was set under jwt — so it
would happily ship a pod in which every request fails this way. It now
attempts the logon when it can:
- refused → the check FAILS and says no deployment setting will rescue it;
- accepted → reported as verified;
- cannot tell →
ok, with the exact command to settle it — never silently a pass.
Two constraints, each with a test: it uses raw teradatasql, never
tdml.create_context, because teradataml holds one process-wide context and a
preflight must not replace the caller's working connection; and it reads the
token from the named TDFS4DS_TD_LOGDATA, never the platform's unprefixed
OAUTH_ACCESS_TOKEN — a preflight forwarding some other component's credential
to the database is precisely what that rule exists to prevent.
Only a credential refusal (8017 / 28000) counts as a "no"; an unreachable host is inconclusive, not a verdict on the token.
[0.3.1.197] - 2026-09-11¶
Documented - which AgentOps auth mode to use, and the condition on it¶
auth's entire documentation was "Credential model baked into the generated
agent." — a capability statement with no recommendation, and the docs table
framed the choice as "when a password exists" rather than which is better.
Prefer jwt for a deployed pod, and note the reasoning is the opposite of
the notebook case, where a token expiring in two hours is precisely what makes a
.env stop working:
- the token arrives per request from the caller, so expiry self-heals;
- the pod connects as the caller, so permissions and audit reflect the real user rather than one shared account;
- no durable database password sits in
runtime_config, which is rendered in clear in the deployment UI.
service stays the default, because jwt is conditional on something outside
tdfs4ds: the platform's auth_context.token must be accepted by Vantage as a
JWT logon. A token minted for a platform API is not automatically a database
credential — this session watched the MCP gateway accept a platform bearer and
the database then refuse the logon it attempted with it (Error 8017). A pod
cannot mint its own, so where the platform token is not a valid Vantage JWT,
jwt is not deployable at all.
The docs, the docstring and the fs-deploy-agentops skill now carry the
one-line test that settles it before deploying:
[0.3.1.196] - 2026-09-11¶
Changed - the auth mode is asked, not defaulted silently¶
auth now defaults to None, which asks when there is a terminal and the
platform is AI Studio, and falls back to 'password' when there is not. An
explicit auth='password' or auth='jwt' is never second-guessed.
It is the one choice in the generated file that cannot be derived and that changes how every component connects, so it belongs to the user. The prompt states the trade-off rather than just listing options:
- password — asked once and reused for the Vector Store; works indefinitely, and is the only option that still works after the notebook is closed. A real password sits in the file.
- jwt — your SSO identity, no password on disk, but the token expires about two hours after issue and the file then fails with errors that never mention expiry.
And the part that is easy to get wrong: neither is more secure on disk. A live bearer token is as sensitive as a password while it lasts. The real difference is how long the file stays valid.
[0.3.1.195] - 2026-09-11¶
Fixed - the TLS warning fired when nobody had disabled TLS¶
describe_config's footer read not getattr(tdfs4ds, 'INSTRUCT_MODEL_VERIFY_SSL', True).
UNSET is None, and not None is True — so once v0.3.1.194 started
clearing the variable for the pre-write check, the footer announced
"INSTRUCT_MODEL_VERIFY_SSL is False - the API key is sent over a connection
whose peer is not authenticated" about a variable nobody had set.
It now tests is False, the idiom diagnostics.py already used at both of its
own sites. This is the same class of bug v0.3.1.159 fixed in _env_attr_status
— truthiness is not "unset" — recurring in a different place, so it is worth
saying once more: a tri-state setting (on / off / never chosen) cannot be read
with a boolean operator.
[0.3.1.194] - 2026-09-11¶
Fixed - the pre-write check described the session, not the file¶
Staging set what the generated file contains and left everything else alone. So a variable the file deliberately omits kept whatever an earlier run had put in the session, and was then checked as though the file had set it.
That is how a run whose file correctly omitted INSTRUCT_MODEL_VERIFY_SSL still
reported TLS verification DISABLED, warned on every call, and printed
INSTRUCT_MODEL_VERIFY_SSL=False has NO EFFECT here — the value came from the
previous run, not from the file under test. The same applied to a leftover
MCP_PASSWORD holding <set-this>.
Staging now clears any variable the generator manages but did not set, so the
check describes the file as a fresh process would load it. Clearing is scoped to
the families this generator owns (TD_*, VECTOR_STORE_*, TERADATA_AI_*,
INSTRUCT_MODEL_*, EMBEDDING_MODEL_*, MCP_*, STRUCTURED_OUTPUT_METHOD) —
SCHEMA, DATA_DOMAIN and the rest are the user's and survive untouched — and
it is undone on exit like every other staged value.
Added - the stale session is named¶
Clearing them for the check is not enough: they are still live in the running kernel, so the session and the file disagree until it restarts. The generator now lists them before checking, which is the difference between "why does it still say TLS is disabled?" and a one-line answer.
[0.3.1.193] - 2026-09-11¶
Added - llm='ingress': reach LiteLLM over TLS¶
Probing the platform settled what its variables do not say. The in-cluster
service is plaintext-only — a TLS handshake on port 4000 fails with a read
error, and there is no 443 — while
https://{JH_FQDN}/one-td/litellm answers 200, the same one-td ingress
pattern as the MCP gateway.
So there are two routes to the same gateway, and the choice is real:
| route | trade-off | |
|---|---|---|
llm='incluster' (default) |
TD_LITELLM_BASE_URL |
shortest path, and what an AgentOps pod takes — but http://, so the API key and every prompt travel in clear |
llm='ingress' |
https://{JH_FQDN}/one-td/litellm |
TLS, at the cost of a hop |
The in-cluster URL stays the default because it is what the platform publishes.
Whichever is not chosen is written commented out, so switching is an edit. With
ingress, INSTRUCT_MODEL_VERIFY_SSL becomes meaningful again and is emitted;
with incluster it is omitted, because on http:// it only builds a custom HTTP
client and warns on every call.
[0.3.1.192] - 2026-09-11¶
Fixed - a <set-this> placeholder read as a configured credential¶
describe_config reported TDFS4DS_MCP_PASSWORD as set (hidden, 10 chars).
Ten characters is exactly <set-this> — a placeholder left in the session by an
earlier run, hidden by the very redaction meant to protect it, and then sent to a
server as if it were a password.
That cost a wrong diagnosis. v0.3.1.190 concluded from the resulting
Error 8017 that the Enterprise MCP gateway rejects Basic auth outright. The
password in that run was the placeholder, so the failure is explained without any
claim about Basic — and whether Basic works with a correct password is still
untested.
PLACEHOLDER now lives in config.py, beside the code that has to recognise it:
a variable holding it reports as PLACEHOLDER - never filled in and is listed
under MISSING, never as set.
Fixed - the 8017 hint blamed credentials that were never sent¶
With a Bearer token, MCP_USERNAME/MCP_PASSWORD are not on the wire at all —
MCP_SERVER_HEADERS takes precedence — yet the hint sent the reader to check
them. It now branches on what was actually sent:
- Basic — those two variables were forwarded; compare them against a credential known to work, and watch for a leftover placeholder.
- Custom headers — the token was accepted (an invalid one is refused
earlier and differently, as
Invalid JWT token), so the 8017 is the server's own database logon on your behalf. tdfs4ds sent no password, so no tdfs4ds setting will fix it.
Fixed - INSTRUCT_MODEL_VERIFY_SSL emitted for a plaintext endpoint¶
AI Studio's LiteLLM service is http://, so there is no TLS to verify.
Mapping TD_SSL_VERIFY=false onto it disabled verification that was never
happening, built a custom HTTP client for nothing, and logged "the API key is
sent over a connection whose peer is not authenticated" on every single
call — eight times in one check_configuration() run.
The generator now omits it for an http:// URL and offers it as a commented slot
instead; it is still emitted for https://. The transport check reports
INSTRUCT_MODEL_VERIFY_SSL=False has NO EFFECT here when the URL is plaintext.
[0.3.1.191] - 2026-09-11¶
Fixed - the MCP bearer was already in the environment¶
0.3.1.190 added a device flow for the Enterprise gateway's token and stated
that OAUTH_ACCESS_TOKEN was "issued for a different audience". That was
wrong. Decoded from a live notebook, the session token carries the same
issuer, the same client_id (onetd-<SITE>) and the same scope
(openid profile email) that the device flow asks for. It is very likely the
right token, and it needs no browser.
mcp='gateway' now uses it — after an explicit override and after MCP_TOKEN,
before offering the device flow. It expires about two hours after issue, which
is what generate_mcp_token() remains for.
Fixed - the device flow reported its own parsing, not the IdP's refusal¶
A refusal surfaced as a bare 'device_code' KeyError, because the payload was
indexed directly. It now reports the HTTP status and the IdP's own
error_description, and on a 400/401 points at IDP_CLIENT_ID and the
device-code grant.
Changed - the IdP is read from the platform, not guessed¶
AI Studio publishes IDP_BASE_URL, IDP_CLIENT_ID, IDP_TOKEN_URL and
TD_KEYCLOAK_BASE_URL (the device-authorization endpoint itself). All four are
used, so discovery is a fallback rather than the first move.
This matters more than it sounds: which IdP a site uses is not derivable from
its hostname. A site whose FQDN says preprod was observed pointing at the
staging provider, while every hand-written snippet hardcodes production — so
the device flow would have gone to an identity provider that has never heard of
the client.
Fixed - describe_config reported a variable that had just been set¶
The pre-write check stages values, but non-pass-through variables are staged on
the module while describe_config defaults to source='env'. A model chosen
seconds earlier was reported NOT SET (required). It now reads source='module',
where pass-through variables still come from os.environ — which staging also
sets.
[0.3.1.190] - 2026-09-11¶
Fixed - the generator never actually asked anything in a notebook¶
_interactive() gated on sys.stdin.isatty(). A Jupyter kernel's stdin is not
a terminal, yet input() and getpass() work there perfectly — they prompt in
the notebook over the kernel's stdin channel. So on AI Studio, the one
environment this generator exists for, every question was silently skipped and
the file came out full of <set-this> with no prompt ever shown.
It now recognises a ZMQInteractiveShell as interactive, and _ask treats a
prompt that cannot be displayed (ipykernel's StdinNotImplementedError, under
nbconvert or papermill) as a missing answer rather than a crash.
Fixed - "no API key" was written as None, which cannot build a client¶
None, 'none' and '' all load as Python None, and the OpenAI SDK then
refuses to construct a client at all — "The api_key client option must be
set" — unless OPENAI_API_KEY happens to be exported. The instruct model
failed before reaching the gateway, with an error naming a variable nobody set,
and the report's hint pointed at Bedrock and GCP credentials.
The sentinel is now EMPTY, vLLM's own convention for a server that does not
check keys, which survives _coerce as a real string. The preview shows it
verbatim instead of set (hidden, 4 chars), which had concealed that no key was
given. A new hint matches the missing-api-key error specifically, before the
cloud-credentials one.
Fixed - the Enterprise MCP gateway wants a bearer token, not a password¶
0.3.1.188 wrote MCP_USERNAME / MCP_PASSWORD for the one-td gateway and
called it "the verified combination". It is not: the gateway authenticates
against the platform's identity provider, and database credentials come back as
the database's own Error 8017, The UserId, Password or Account is invalid —
which reads like a wrong password and is really the wrong kind of credential.
tdfs4ds.generate_mcp_token() performs the OAuth 2.0 Device Authorization
Grant: it prints a URL and a short code, opens a browser, and polls until the
token is issued. The client id is onetd-<SITE>, where <SITE> is JH_FQDN's
first label upper-cased (tdfs4ds.mcp_site_id()); MCP_IDP_URLS carries the
prod, staging and dev identity providers. It never raises — a failure leaves you
free to paste a token instead.
mcp='gateway' now emits MCP_SERVER_HEADERS with that bearer, taking an
existing MCP_TOKEN from the environment, else offering to fetch one, else
reporting it as a gap. This token is not OAUTH_ACCESS_TOKEN: that one is
the notebook session's, issued for a different audience.
Enterprise remains the default for AI Studio. The commented community line is
the in-cluster alternative; the active block is now labelled ACTIVE and the
alternative ALTERNATIVE (commented out), since that was read as the gateway
itself being disabled.
Fixed - a LiteLLM web console reported as an intercepting page¶
The transport check probes /chat/completions then the base URL. LiteLLM's root
serves its web console, so a healthy gateway was flagged INTERCEPTED: 200
text/html. An API answering a bare GET with 405/401/404 is the API, so the
probe now stops there and never reaches the base URL.
[0.3.1.189] - 2026-09-11¶
Added - probe what the endpoint actually accepts: JSON constraints and TLS¶
Two configuration failures kept surfacing late, and both were invisible to
check_configuration().
There are TWO structured-output systems, and only one was ever probed¶
| Path | Used by | Mechanism | Setting |
|---|---|---|---|
langchain with_structured_output |
consumer agent, query optimizer | json_schema → function_calling → json_mode |
STRUCTURED_OUTPUT_METHOD |
| provider-branched wire parameter | documentation generator | guided_json / response_format / format= |
INSTRUCT_MODEL_PROVIDER |
The second was never probed, and it retries once unconstrained when the backend rejects its parameter. So a provider whose syntax the endpoint refuses left documentation and the business dictionary running on prompt-guided JSON — plausible output at strictly lower reliability — while every check stayed green.
Found while fixing it: the guided_json 422 hint could not fire for its own trigger.
It was reachable only from a stage-1 instruct failure, and stage 1 is
llm.invoke("Reply with exactly: …"), which never sends guided_json.
A new JSON constraints check probes both paths. The instruct check's second
stage moves into it, so a row moves in the report and instruct_model goes back to
meaning what its label says: can the model answer at all.
tdfs4ds.probe_json_constraints() (agent/json_constraints.py,
also python -m tdfs4ds.agent.json_constraints) is a nine-request wire sweep:
response_format json_schema with and without strict, tool calling, json_object,
and the four vendor extensions (guided_json, nvext.guided_json, grammar,
format). There is no single industry parameter for constrained decoding, so the
accept/reject pattern identifies the serving stack behind the gateway — which no
amount of configuration inspection can. It runs only once a cheaper probe has
already reported trouble; nine billable completions on every healthy check would be
absurd, and a test enforces it. Engine inference is fail-open: a gateway accepting
several mutually exclusive vendor extensions is ignoring unknown fields, so acceptance
proves nothing and it says "not identified" rather than guessing.
Live-verified against api.mistral.ai — which accepts strict, contradicting the
assumption in _STRICT_JSON_SCHEMA_PROVIDERS. The engine label therefore describes
the capability, never a vendor name.
A silent unconstrained fallback now fails the check. That is a deliberate
behaviour change: a configuration that was quietly wrong reports as wrong, and the
hint names INSTRUCT_MODEL_PROVIDER — not STRUCTURED_OUTPUT_METHOD, which that path
does not use.
probe_structured_output also records what it learns (new remember_method()).
It called with_structured_output directly, bypassing the seam, so running the
diagnostic discovered the working method and the next real call still paid to
rediscover it.
TLS is now reported, not merely configured¶
Nothing anywhere inspected a peer certificate. TLS appeared only as an echo of your own settings or as a substring match on an already-failed call — so an expiring certificate was invisible until the day every LLM call failed at once.
A new LLM transport (TLS) check runs before the instruct model and sends no
credential, so its answer frames everything after it. It handshakes with
INSTRUCT_MODEL_URL using the configured trust and reports issuer, subject, TLS
version and days to expiry, separating three failures that look identical in a
traceback and need three different fixes:
| Reported | Fix |
|---|---|
| untrusted issuer | INSTRUCT_MODEL_CA_BUNDLE — and if the system store accepts the certificate, it says so, because then the bundle is simply the wrong one |
| hostname mismatch | the host in INSTRUCT_MODEL_URL; the certificate's real name is reported |
| expired | renewal on the server |
| under 30 days left | nothing yet, which is the point of saying so now |
With verification off it still reads the certificate (unvalidated) so you can see
which CA to install and turn verification back on. Naming an untrusted issuer uses
ssl._ssl._test_decode_cert — the only certificate parser in the standard library,
private, wrapped, and never load-bearing. Verified against real valid, expired,
hostname-mismatched, self-signed and DNS-failure endpoints.
It skips when a proxy is configured (a direct socket proves nothing about the proxied path and would just fail on blocked egress) and when no URL is set (Bedrock and Vertex secure their own transport).
It absorbs _redirect_probe, which previously ran only when an error had already
matched the redirect hint. Two false positives had to be fixed once it ran always: a
non-JSON body is interception only on a 200 (a 4xx HTML page is the server's own
error page), and a redirect that merely normalises the URL (/v1 → /v1/, http →
https) is not interception at all.
Instruct-only by design. _EMBEDDING_HINTS gained a certificate entry saying so
and naming the real fixes, because a proxy fallback cannot weaken security while a
verification fallback could.
Fixed - list_instruct_models ignored the TLS and proxy settings¶
Both it and list_embedding_models used a bare httpx.get against the same
endpoint the chat client talks to — so behind an internal certificate authority they
failed while the model itself worked, and INSTRUCT_MODEL_PROXY never applied to them.
Both now go through the new embedding.http_get.
Added - patch_dotenv: change one variable without rewriting the file¶
export_config and generate_ai_studio_env rewrite a .env whole, losing its
comments, its ordering and the commented-out alternatives a generated file
deliberately carries. tdfs4ds.patch_dotenv(path, updates, *, comment=None,
allow_secrets=False) changes one line.
It targets the last uncommented occurrence — the loader assigns as it reads, so on
a duplicate the last one wins, and patching any other would leave the file saying one
thing and meaning another — else the first commented slot (#TDFS4DS_X=,
uncommented in place), else appends. It never enables a commented alternative
while a live line exists, since a generated file carries two MCP_SERVER_URL lines on
purpose.
The comment is a trailing note, so re-running replaces it instead of stacking one
per run, and it is dropped for a quoted value (read back verbatim, so a trailing
comment would become part of it). Credentials are refused without allow_secrets: a
general-purpose file patcher should not be how a password reaches a file someone later
commits. None is written as the literal None and loads as None.
check_configuration(..., pin=True) uses it to record what the JSON-constraints
check discovers, stamped with the endpoint and date it was probed against — so a pin
that stops being true is visible rather than silent. Note a pinned
STRUCTURED_OUTPUT_METHOD travels into a deployed pod's config.yaml.
Changed - generate_ai_studio_env asks, validates, then writes¶
It used to write first and offer to check afterwards, filling anything unanswered with
<set-this>. That placeholder does not work: it loads as the literal string and
fails far from the file that caused it.
- A blank answer is re-asked, with the reason the value is needed.
- The instruct model is chosen from the list the endpoint itself serves, defaulting to a Ministral 14B when one is offered.
- No API key is written as
None— correct for a gateway that needs none, and honest, unlike a fake key that fails as a 401 elsewhere. - The configuration is staged into the session and checked before the write
(
describe_config()thencheck_configuration()), then the session is restored. - Gaps or failing checks block the write until confirmed; a non-interactive caller
gets nothing written and the preview returned.
force=Truewrites anyway. - A successful write ends with
load_config(), so the session is configured when the call returns.
check now defaults to True, and pin=True records the probe's finding into the
file just written. Two commented slots are emitted — INSTRUCT_MODEL_CA_BUNDLE and
STRUCTURED_OUTPUT_METHOD — so turning either on is an edit.
The superseded root-level probe_json_mode.py scratch script is removed; the packaged
module and its python -m entry point replace it.
[0.3.1.188] - 2026-09-11¶
Added - generate_ai_studio_env(): stop writing the AI Studio .env by hand¶
Configuring tdfs4ds on Teradata AI Studio went wrong quietly — a mistyped gateway URL or an unset variable surfaces much later as a model that "isn't configured" or a Vector Store that refuses a credential — and the only remedy was a hand-maintained template.
But the platform already publishes most of the answer. Mapping a known-working configuration
against a real notebook's os.environ: 16 of 22 variables need no human input at all.
tdfs4ds.generate_ai_studio_env() and tdfs4ds.detect_ai_studio()
(ai_studio.py) — 9 derived, 7 constant, 6 asked:
| Target | Source |
|---|---|
TD_USERNAME, TD_DATABASE |
the …/teradata_username JWT claim inside OAUTH_ACCESS_TOKEN |
INSTRUCT_MODEL_URL |
TD_LITELLM_BASE_URL |
VECTOR_STORE_AUTH_BASE_URL |
TD_VECTORSTORE_BASE_URL, minus its /data-insights suffix |
INSTRUCT_MODEL_VERIFY_SSL |
TD_SSL_VERIFY |
MCP_SERVER_URL |
https://{JH_FQDN}/one-td/mcp |
TD_HOST, VECTOR_STORE_BACKEND, TERADATA_AI_API_TYPE, … |
constant for AI Studio |
| the password, the API key, the instruct model | asked — no platform source exists |
And in password mode three of those six are the same database password, so it is asked
once and fanned out to TD_PASSWORD / VECTOR_STORE_AUTH_PASSWORD / MCP_PASSWORD. That is
also why _JWT_DROP is defeated in practice: one secret travelling under three names.
It writes real values and returns a redacted preview (set (hidden, N chars)), so the
output is safe to leave in a saved notebook. This deliberately departs from export_config's
return-the-text idiom, for the reason v0.3.1.187 had to clean up: returning that text is how a
live password and API key reached a stored notebook output.
Quoting is a correctness requirement, not a style choice. _parse_dotenv handles quotes
and trailing comments in an if/else, so a quoted value is verbatim while an unquoted
abc #def loses everything from the whitespace. Every secret is therefore written quoted,
with a single-quote fallback when the value contains " and a bare write plus a warning when
it contains both. A round-trip test drives this with a #-bearing password (the real shape), a
whitespace-then-# value, and one containing a quote.
auth='jwt' writes no password at all — the platform token serves the database
(TD_LOGDATA), the Vector Store (VECTOR_STORE_AUTH_AUTH_TOKEN, which is tried before
Basic) and MCP (Authorization: Bearer). Stated plainly in the docstring and in the generated
file: the token expires about two hours after it was issued (exp - iat = 7200), so this
trades a durable secret for a short-lived one rather than making the file non-secret. The
variant not chosen is emitted commented out, so switching is an edit rather than a rerun.
mcp='gateway' (default) is https://{JH_FQDN}/one-td/mcp with edition=enterprise — the
verified 132-tool server. mcp='incluster' uses TD_MCP_BASE_URL, whose host implies the
Community edition, which embeds its own credentials so tdfs4ds sends none. The alternative
is always written commented, because the edition decides whether credentials are sent at
all and the platform's own TD_MCP_BASE_URL is not the server that was verified.
Anything neither derived nor answered becomes <set-this> plus a # MISSING: block rather
than an exception — so prompt=False, a declined prompt and a TTY-less script are all the
same, testable path. check=True reloads the file and runs describe_config() +
check_configuration().
The unprefixed-variable rule is respected, not reversed. tdfs4ds still never reads
OAUTH_ACCESS_TOKEN at runtime (the v0.3.1.154 decision). This is the tool that performs the
explicit, one-time, user-invoked mapping the docs already described by hand, writing a
named TDFS4DS_* variable so the resulting configuration states its own sources.
Reuses config._is_secret as the single authority on what counts as a credential — the
per-item flag can only add caution, never remove it. 30 offline tests
(tests/test_ai_studio.py): no DB, no network, no real secrets.
Fixed - TDFS4DS_TD_HOST_POD was never a registered variable¶
Introduced in v0.3.1.187's notebook pass. It is in neither _ALL_ENV_VARS nor
_PASSTHROUGH_ENV_VARS, so _apply_dotenv logged unknown .env variable
'TDFS4DS_TD_HOST_POD' — ignored on every load. Notebook 06 still worked, because it reads the
value with a plain os.getenv rather than through the config layer, but the warning was noise.
It should never have worn the prefix: it is not a tdfs4ds setting but a notebook-side
staging value that becomes TDFS4DS_TD_HOST in the deployment's runtime_config. Renamed
AGENTOPS_POD_TD_HOST. Registering it instead would have been wrong — it would claim a
configuration slot the package does not have. A test now asserts every name the generator
emits is a registered variable, so the class of bug cannot recur.
Changed - notebook 00 demonstrates the generator; off-platform behaviour documented¶
notebook dev/agentops/notebooks/00-Setup.ipynb
went from 18 to 26 cells. Its section 1 was a template to copy and fill in; it now calls
detect_ai_studio() and generate_ai_studio_env(), with the old template demoted to a
reference for reading the generated file. New section 1b lays out the password-vs-token
tradeoff. Section 4 pairs describe_config() ("is it set?") with check_configuration()
("does it work?").
Two pre-existing defects fixed there: %gen_token was in a raw cell, so it never
executed, and an empty code cell was left over from the previous pass. Both notebook cells
that touch the generator degrade with a message on an older tdfs4ds rather than raising.
Off AI Studio nothing changes. The new module is stdlib-only, imported eagerly like
read_federation, and consulted by no existing code path. detect_ai_studio() returns an empty
report rather than raising; the generated file labels its AI-Studio constants (TD_HOST=dbc,
VECTOR_STORE_BACKEND=teradata, …) as assumptions, because they are wrong elsewhere; and the
summary names export_config(fmt='env', source='module')
as the right tool there. deploy_to_agentops off-platform already failed in its own client and
fails no differently.
Documented - $VAR is not expanded in a .env¶
.env.example, README.md and docs_src/getting-started/connect.md recommend
TDFS4DS_VECTOR_STORE_AUTH_AUTH_TOKEN=$OAUTH_ACCESS_TOKEN. tdfs4ds's own parser does no
variable expansion, so that line is read as the literal string — it works only because
python-dotenv's load_dotenv() (or a shell, or a Docker --env-file) interpolates before
tdfs4ds sees the file. Anyone relying on tdfs4ds's auto-discovery gets the literal. Now stated
in connect.md and in a new "Quoting, comments and $VAR" section in
docs_src/configuration/dotenv.md, which also documents the trailing-comment and quoting
rules. generate_ai_studio_env writes resolved values for exactly this reason.
[0.3.1.187] - 2026-09-10¶
Version 0.3.1.186 was published to PyPI before these changes were made, so this work ships as .187. A published version is immutable - reusing the number would leave two different builds answering to it, and the deployment publication gate compares the running version against the index by name alone.
Added - describe_config(): is this variable set?¶
export_config(include_secrets=False) renders a secret as a commented-out placeholder, which
reads identically whether the variable holds a password or nothing at all — so it cannot
answer the question you actually ask when a deployment misbehaves. describe_config()
(config.py, re-exported on tdfs4ds) does:
TDFS4DS_TD_HOST dbc
TDFS4DS_TD_PASSWORD set (hidden, 16 chars)
TDFS4DS_MCP_PASSWORD NOT SET (required)
describe_config(*, source='env', required=None, display=True). A credential reports its
length — enough to catch a truncated paste or a stray quote, never enough to reconstruct.
An unset variable is listed only when named in required=, so the report stays readable
against ~60 possible variables. source='env' reads os.environ (and therefore works despite
the load-order trap); source='module' snapshots the running config, matching
export_config's existing source= semantics.
Three contracts, each with a test: it reuses _is_secret rather than re-deriving the rule
(a second "is this a credential?" authority is what goes stale and leaks); every hidden row
returns value=None, so a caller who prints the return value cannot leak one either; and
the output is pure ASCII, per the _print_report convention — a diagnostic must never be
the thing that raises UnicodeEncodeError on a legacy Windows console.
Why it exists: the leak it prevents already happened. Printing the whole environment to
confirm that .env had loaded is what put a live database password and a live LiteLLM API key
into the stored output of a demo notebook.
Fixed - MCP_USERNAME and MCP_AUTH were committed into config.yaml in clear¶
config.yaml ships inside the deployment zip and is committed, so it carries only what is
safe to read. classify() routed a setting to runtime_config on _is_secret(attr) or a
TD_ / VECTOR_STORE_AUTH_ prefix — and two credential-adjacent MCP settings passed neither
test:
MCP_USERNAMEis half a credential pair. Its partnerMCP_PASSWORDis a credential and already routed toruntime_config, andMCP_USERNAMEfalls back toTDFS4DS_TD_USERNAME(the Teradata MCP server authenticates with the database identity) — so the archive carried the database username in clear while the password travelled separately.TD_USERNAMEis shielded only by its prefix, not by being secret, so the same identity was protected under one name and exposed under another.MCP_AUTHis an auth-mode override:build_mcp_headers()resolves Basic from it before falling back to the edition, so a committed file could change how the pod authenticates.
Both now route to runtime_config via a new _DEPLOYMENT_VARS
(agentops/config_map.py), the sibling of the existing
_DEPLOYMENT_PREFIXES and sharing its rationale ("runtime even when not secret, because it
identifies a deployment"). Deliberately not added to config._is_secret: that is the
package-wide authority on "is this a credential?" and neither of these is one — "may this be
committed?" is a different question, which is precisely why _DEPLOYMENT_PREFIXES exists.
Backward-compatible by construction. Under mcp=False — the default, and the documented
first deployment — every MCP_* setting is dropped before this branch is reached, so an
existing package is byte-identical (asserted). The change is observable only under
mcp=True.
Documented - runtime_config is not a vault¶
Verified against a live AgentOps deployment UI, and correcting wording in
fs-deploy-agentops, docs_src/deployment/agentops.md and CLAUDE.md that implied otherwise.
The UI's Config.json panel is runtime_config rendered back by the platform (plus
platform-injected OPIK_*) — not the zip's config.yaml, none of whose settings appear
there. And it displays the values in clear, readable by anyone who can view the deployment.
So runtime_config's real guarantee is "not committed to source control", which is weaker
than confidential; package.py's "not on disk" is accurate about the zip and says nothing
about the deployment record. The destination still matters — git history is permanent and
widely readable, whereas a deployment record can be deleted and its credentials rotated — but
that is durability and blast radius, not secrecy. Treat a deploy as publishing its
credentials.
Two further findings recorded from the same deployment:
_JWT_DROPcan be defeated in practice. Underauth='jwt',TD_PASSWORDis dropped because "a password shipped to the pod is never used". ButVECTOR_STORE_AUTH_PASSWORDcommonly holds the same database password, so the identical secret reaches the pod under another name and is displayed anyway.auth='jwt'is a mixed auth model, not a per-caller one. The database connection is per-caller (TD_LOGDATA+ a fingerprint-keyed reconnect) and MCP gets the caller's Bearer per turn, but the Vector Store authenticates with a fixed shared username/password fromruntime_config— so vector search runs as one service identity no matter who calls. The caller's token could serve it (resolve_auth_mechanismtries JWT first, andVECTOR_STORE_AUTH_AUTH_TOKENis merely unset), but the generated agent never wires it and the obstacle is a lifetime mismatch: Vector Store auth is configured insideconnect(), which runs once per process, while a token is per-caller and expires. Notebook 06 ships a probe cell for whether the service accepts an AgentOps-issued token at all; wiring it is gated on that result.
Changed - the AgentOps demo notebooks¶
notebook dev/agentops/. Notebook 06 was 4 code cells with zero
markdown and 16 stored outputs holding a live password, a live API key, three
*.svc.cluster.local addresses, an internal hostname, a private IP and a database id. The
outputs were also stale — cell 1's four lines cannot have produced the environment dump
attached to them — so they were stripped rather than edited. Nothing had reached git.
Rebuilt as 26 cells (12 markdown) in the series' voice, with the environment read from .env
instead of hardcoded. The substantive change is that deploy and verify are now two separate,
re-runnable steps: report["ok"] is assigned before the smoke test precisely so a
verification problem cannot read as a failed deploy, AgentOps deployment is asynchronous,
and under auth='jwt' the pod's lazy bootstrap means its startup banner is only written on the
first caller turn — so verified: False with "no startup confirmation" straight after a
deploy is the expected reading, not a broken demo. The notebook now says so and calls
check_deployed_agent in its own cell.
Notebook 00: the internal preprod MCP hostname is replaced with a placeholder; the
!pip install <local wheel> that sat in a markdown cell became a code cell pointing at
PyPI (a local wheel satisfies the notebooks and is invisible to the pod's image build, which
contradicts notebook 06's publication gate); the .env template documents
TDFS4DS_TD_HOST_POD and the TD_LOGMECH constraint, and no longer mislabels a
username/password group as the PAT mechanism.
[0.3.1.186] - 2026-09-10¶
Published to PyPI from an uncommitted working tree, so there is no commit carrying this version and no record here of what changed in it. The entry exists so the public version sequence has no silent gap: anything looking for 0.3.1.186 in the history will not find it, and that is the fact, not an oversight in the search.
[0.3.1.185] - 2026-09-01¶
Documented - RESOLVED: an AgentOps pod has no DNS for the database; use the IP address¶
The live deployment now works end to end. The blocker was never the credential, the auth mode or
MCP: TDFS4DS_TD_HOST='dbc' does not resolve from inside an AgentOps pod, and neither did any
qualified form - DNS for the database was unavailable there entirely. Setting TDFS4DS_TD_HOST to
the database's IP address was the fix.
The v0.3.1.183/.184 diagnostics offered only the fully-qualified in-cluster name, which this proves
insufficient. Both the host_unresolved insight and the generated agent's own diagnosis now name the
IP address as the remedy when no name resolves, and both note the cost: an IP pins the deployment
to an address that can change.
New Connecting to Teradata from a pod section in the docs and the fs-deploy-agentops skill,
recording what the episode actually taught:
- The notebook is not evidence for the pod. A bare name is completed by the resolver's search
domains and the two workloads need not share them - so the same value succeeds in one and fails in
the other, with nothing in the error pointing at a name. That the other in-cluster services are all
reached by fully-qualified name is what made a bare
dbclook normal. - Classify a
TDML_2000by elapsed time, since the message is identical for three faults: well under a second = the name never resolved (observed: 88 ms); seconds then failure = resolved but unreachable, a platform question; a round trip plusSQLSTATE 28000= reached and refused, the credential. - A connection failure is never evidence about the credential. The identical failure appeared
under
auth='service'(TD2) andauth='jwt'. Switching auth mode to test connectivity spends a deploy cycle learning nothing. Runtime: X | Mode: N/Ais a consequence, not a second fault - underservicethe connection happens at import, so a host failure is fatal and the runtime never constructs; the pod serves/healthwhile the agent does not exist, which reads as unhealthy. Underjwtthe same fault appears per request, because that path connects lazily.JWT Auth: ... | Authorization: disabledis inbound auth to the agent's HTTP endpoint and says nothing about the Teradata connection.- AgentOps does support a direct database connection. Nothing was blocked; there was no name to connect to. MCP was never a required data path, and could not be one - every non-MCP skill reads the catalog over the ordinary teradataml connection.
[0.3.1.184] - 2026-09-01¶
Added - the generated agent diagnoses its own connectivity failure¶
TDML_2000 Failed to connect to Teradata Vantage. renders identically for an unresolved name, an
unreachable host and a rejected credential - three faults with three different fixes. A pod is not a
shell you can poke at, so each hypothesis costs a full redeploy: on a live deployment,
host=dbc failing in 88 ms took several cycles to attribute.
Both generated variants now wrap reconnect_teradata and, on the failure path only, log a
diagnosis before re-raising:
- resolve the configured host, and report what it resolved to or the resolver error verbatim;
- if it resolved, TCP-probe the port - reachable there means the name and route are fine and the fault is the credential;
- if it did not, print the resolver's own search domains from
/etc/resolv.confand what a bare name becomes under each. A bare name likedbcis completed by those domains, and an AgentOps pod need not share the notebook's namespace - so this prints the fully-qualified name that works instead of leaving it to be guessed.
Costs nothing on the happy path, uses only the standard library, and never raises: it runs inside an error handler, where raising would replace the real failure with its own.
Five tests, including an AST check that the handler both diagnoses and re-raises, and that a name which cannot resolve is never TCP-probed.
[0.3.1.183] - 2026-09-01¶
Fixed - reconnect_teradata logged the wrapper and threw the cause away¶
logger.error(..., str(exc).splitlines()[0]) kept the first line only. teradataml puts
(TDML_2000) Failed to connect to Teradata Vantage. there and wraps the driver error rather than
replacing it - so the actionable half lives in __cause__. A DNS miss, a refused connection and a
rejected logon all render identically at the top level, and they have three different fixes.
Found on a live AgentOps pod log: reconnecting to Teradata host=dbc user=DM5344095 at
07:53:22,903, failed at 07:53:22,990. 87 milliseconds - far too fast for a TCP timeout, so a
name that never resolved - and nothing in the log said so. _describe_exception now walks the
__cause__/__context__ chain (bounded, cycle-safe, tolerant of a __str__ that itself raises,
since it runs inside an error handler).
Third instance of this pattern after v0.3.1.176 and v0.3.1.179: do not truncate an error to its first line or a fixed short width - the half that names the fault is routinely the later half.
Added - host_unresolved, ahead of the generic connect_failed¶
Now reachable, because the chained gaierror finally reaches the log. A bare name like dbc
resolves through the namespace's search domains, and an AgentOps pod need not share the notebook's
namespace - so a host that works in the notebook can fail in the pod. The insight says to use the
fully-qualified in-cluster form the other services already use
(<service>.<namespace>.svc.cluster.local), and gives the timing tell: sub-second means unresolved,
a TCP timeout means unreachable, SQLSTATE 28000 means refused.
Ordered before connect_failed so a specific fault is not reported as "could be the host, could be
the token".
[0.3.1.182] - 2026-09-01¶
Fixed - auth='TD2' raised without saying which argument the user wanted¶
auth= names whose credential the pod uses ('service' = its own TDFS4DS_TD_*, 'jwt' = the
caller's, per request). TD2 / LDAP / JWT name the Teradata logon mechanism, which travels
separately in TDFS4DS_TD_LOGMECH. Two orthogonal axes whose vocabularies overlap on JWT - so a
user wanting a username/password pod reasonably reaches for auth='TD2' and got only a list of the
legal values, which does not say which one they want.
The error now distinguishes the axes and, for a recognised mechanism, names the fix: use
auth='service' and set TDFS4DS_TD_LOGMECH=TD2. TD2 was always supported; it never had a name a
user would guess.
Added - service identity with a leftover TD_LOGMECH=JWT is refused at preflight¶
The mirror of the v0.3.1.175 bug, in the other direction. Under auth='service' the session's
TD_LOGMECH is passed through, so a JWT left over from experimenting with auth='jwt' ships a
pod calling create_context(username=..., password=..., logmech='JWT') - incoherent, and it fails at
startup with a message about credentials rather than about the mechanism. The connection_env
check now fails when TD_LOGMECH=JWT accompanies a username and password with no TD_LOGDATA, and
names both ways out. auth='jwt' is untouched: it sets the mechanism itself.
The OK detail also reports the resolved mechanism (service identity, logmech=TD2), so what the pod
will actually do is visible in the report rather than inferred.
Eight tests, including that an unrecognised value still explains both modes without guessing a mechanism.
[0.3.1.181] - 2026-09-01¶
Changed - ok is the deployment's verdict; verified is the smoke test's¶
report["ok"] = bool(smoke["ok"]) made the two one flag, so a live run whose pod was active with
a working URL reported ok=False because a smoke check that depends on pod warmth had failed -
sending the user to debug a deployment that was fine.
They are now separate, and the failing-verify hint says so explicitly ("The DEPLOYMENT is active - this is the agent's behaviour, not the deploy"):
ok- the deployment reachedactiveand has a URL. Set before the smoke test runs, so a verification problem can never read as a failed deploy.verified-True/False, orNonewhen it did not run. A tri-state, because "not checked" and "checked and failed" are different things.
Added - wait for the pod's startup block instead of reading the log a second after active¶
active is the platform's verdict on the pod, which it reaches before tdfs4ds has finished its
module-scope work (connect(), the configuration check, the graph build). The pipeline read the log
about a second later, found no startup block, and said no startup confirmation in the log on a
healthy deployment - and that line sat next to a real failure, so it read as corroboration.
_wait_for_startup polls the log (5 s) up to startup_timeout=120 seconds, ending early as soon as
the startup block appears or an error signature does - an error is a conclusion, and waiting
past it delays the diagnosis by the whole timeout for nothing. Bounded and fail-open: a log that
never confirms anything still returns whatever it held.
Added - verify=False, for deferring verification¶
Returns as soon as the deployment is active, and the skipped stage names the follow-up:
check_deployed_agent(report['agent_url'], token=...). For a pod that is slow to warm, or when the
caller holding a usable token is not the one running the deploy.
Six tests: the wait returns on arrival / on an error / at the timeout / not at all when there is
nothing to wait for, an active deployment with a failing smoke test reports ok=True with
verified=False, and verify=False never calls the smoke test.
[0.3.1.180] - 2026-08-31¶
Added - connect_failed: a session that never landed is not a refused credential¶
A live JWT deployment reached the Playground, loaded, answered - and returned
[Teradata][teradataml](TDML_2000) Failed to connect to Teradata Vantage. No signature matched it,
so analyze_pod_log reported nothing and the run said only "no startup confirmation in the log".
It sat in the gap between the two existing signatures: no_credentials (nothing was supplied) and
token_rejected (supplied and refused). A bare TDML_2000 is neither - nothing was refused,
because no session was ever opened - and the fix is the opposite one. The new signature names both
candidates:
TDFS4DS_TD_HOST. The notebook and the pod are different workloads and need not resolve the same name, so a host copied from a working notebook is not evidence it works in a pod.- Under
auth='jwt', whether the platform'sauth_context.tokenis usable as a Vantage JWT at all. A token minted for the AgentOps API is not necessarily accepted by the database, and a pod cannot mint one of its own - so where it is not accepted,auth='jwt'is not deployable in that environment andauth='service'with a real account is the way in.
Ordered after token_rejected, because teradataml wraps a refused logon in TDML_2000 too:
when both markers appear the specific one leads, and the wrapper follows. A test pins that order,
and pins that a bare TDML_2000 yields connect_failed alone.
Fixed - the structured_output insight recommended a mechanism that does not work¶
It advised TDFS4DS_STRUCTURED_OUTPUT_METHOD=json_mode as "the robust choice". Measured directly
against a LiteLLM/Bedrock route: response_format is inert there in every shape - json_object
included - so all five variants returned identical prose and json_mode fails exactly as
json_schema does. tools succeeded on every attempt. The insight now says function_calling, and
says why json_mode is not a safe default.
[0.3.1.179] - 2026-08-31¶
Fixed - get_logs relayed a clipped 422 instead of naming the mistake¶
client.get_logs() takes the deployment's UUID, but the generated deployment name ends with
a uuid4 of its own (tdfs4ds-consumer-agent-<uuid>) - and the name is what the AI Studio Playground
displays, so passing it is the obvious mistake to make. The platform answers 422 ... "field":
"path -> agent_id", "message": "Input should be a valid UUID", whose useful half sits after a
long echoed path and fell off the 300-character truncation, leaving Input should be a valid and
no fix.
get_logs now checks the shape first and says what is wrong: this route takes the UUID, that looks
like the name, use report['agent_id']. The fallback truncation was also raised to 800 characters,
for the same reason it was raised in the structured-output probe in v0.3.1.176 - a 4xx names the
offending field late in the sentence.
Fetching a log is a diagnostic step taken when something has already gone wrong; a diagnostic that fails obscurely costs a round trip at exactly the wrong moment.
[0.3.1.178] - 2026-08-31¶
Fixed - a failed smoke run showed a green row and buried its own finding¶
All three seen together in one live run against a deployment whose pod could not reach Teradata:
reachable failed, and the report still reported latency ok and a second failure from
grounded.
latencycounted a failed call as a measurement. A 404 returning in 100 ms is not a fast agent. Only a HTTP 200 is timed now, so with nothing answering the check reports skipped rather than a green row underneath a failed run.groundedgated on non-empty text instead of the status. A failed call leaves the error body infirst_answer- non-empty - so the check ran, found no catalog name in a 404 page and raised a derived failure over the real one. It now gates onfirst_status == 200, asanswers/session_memory/session_isolationalready did.reachabledid not wait for the gateway route.activeis the platform's verdict on the pod, and the pipeline calls/invokeabout a second later - so a missing route reads as a dead agent. It now retries a bounded 6 attempts / ~25 s, but only on 404/502/503/504. An expired JWT (401/403) or an app-level 500 still fails on the first call: retrying those turns a clear answer into a wait.
Same anti-pattern as the v0.3.1.164/.167 Vector Store findings - ask what a check would still report with the failure present.
[0.3.1.177] - 2026-08-31¶
Fixed - a dry run stopped without saying so¶
deploy_to_agentops promises a numbered [k/7] line per stage, but _skip_rest appended the
skipped stages to the report without emitting them. So a perfectly normal dry_run=True run
printed four lines and fell silent - which reads exactly like hanging on the next HTTP call, the
first thing the pipeline does after packaging. Observed live: stages 1-4 green, "and then nothing
more".
Skipped stages now go through the same _stage emitter as every other outcome, so the log always
accounts for all seven and states the reason (dry run - nothing sent, definition not
registered, ...). This also covers the failure paths, where a FAILED line was previously
followed by the same silence.
The regression test asserts one emitted line per stage and reproduces the old symptom exactly - four lines, then nothing.
[0.3.1.176] - 2026-08-31¶
Fixed - the structured-output probe failed an endpoint over its own class name¶
check_configuration() reported "structured output FAILED every way" against a LiteLLM gateway
where function_calling in fact works perfectly, failing the whole configuration report and the
AgentOps preflight with it.
The cause was in the diagnostic, not the endpoint. On the function_calling path the Pydantic
schema's class name is sent as the tool name, and probe_structured_output declared its schema
_Probe - a leading underscore, which a Bedrock-backed route refuses:
Renamed to Probe. No real call site was affected - all six schemas already in use are
letter-initial (IntentResult, ExplainOutput, CandidatesOutput, PlanComparison,
StrategiesOutput, CandidateRewrite), and the nested _SubIntent /
_ClarificationInterpretation travel in $defs, never as a tool name. So the agent and the query
optimizer were working on this gateway throughout; only the report said otherwise.
Two things made this expensive to find, both fixed:
- The error was truncated to 120 characters, clipping the body mid-phrase at exactly the word that named the cause. The probe's per-method detail now keeps 300 characters (400 for the default path). A gateway's 4xx names the offending field late in the sentence, and that string is the entire diagnosis a user gets.
- It read as a capability failure. A new
_INSTRUCT_HINTSentry recognises a tool-name rejection and says what it is: the schema class name, not the model's ability to produce typed output.
Two tests guard the class of bug rather than the instance: the probe's schema name must match
^[A-Za-z][A-Za-z0-9_]*$, and a source scan rejects an underscore-prefixed schema at any
_structured(...) call site.
Live-diagnosed by probing the gateway directly: response_format is inert there in all four
shapes (json_object, json_schema, +strict, +seed) - all five requests returned identical
prose, so json_mode cannot work either and no client-side parser can rescue that path. tools
succeeded on every attempt. seed was ruled out (seed + json_schema and seed + tools both
behave exactly as without it), which retires the v0.3.1.145 provider='mistralai' workaround as a
hypothesis for this failure.
[0.3.1.175] - 2026-08-25¶
Fixed - a JWT deployment shipped a conflicting logon mechanism and an unused password¶
Found driving a live deployment from a session configured for TD2 user/password auth.
runtime_config carried TDFS4DS_TD_LOGMECH=TD2 straight from the session. The generated JWT agent
sets its own default with os.environ.setdefault(...), which does not override a value already
present - so the pod would call create_context(logmech='TD2', logdata='token=...'), which is
incoherent, and fail at startup with an error about credentials rather than about the mechanism.
Under auth='jwt' the logon mechanism is now normalised to JWT in runtime_config;
auth='service' keeps whatever the session configured.
The same deployment also shipped TDFS4DS_TD_PASSWORD to a pod that authenticates with the
caller's token and never reads it - pure exposure. It is now dropped under auth='jwt'.
TD_USERNAME is kept: some sites scope a token by user, and reconnect passes it through when
present.
classify() gained an auth= argument for both rules. Five tests cover the pair, asserting the
service path is unchanged.
[0.3.1.174] - 2026-08-25¶
Added - deploy_to_agentops: one call from a named store to a verified agent¶
The packaging layer stopped at a zip; everything after it was hand-run REST calls, and each mistake
cost a full image build to discover. Driving one real deployment produced, in order: a payload field
the API rejects (422 extra_forbidden), a missing docker_image, an empty runtime_config that
shipped a pod with no configuration at all, and a version that cannot pip install inside the image.
tdfs4ds.deploy_to_agentops(dest, database=..., token=..., auth=..., mcp=False)
(agentops/deploy.py) runs seven stages - validate target, preflight,
publication gate, package+zip, register, deploy, verify - and a failing stage stops the
pipeline, because registering a definition whose preflight failed only creates registry litter and
hides the earlier cause. dry_run=True stops after the zip and returns the payload.
The feature store is now stated, never inherited. database= is required; database_view,
databases_readonly and data_domain are explicit too. Previously the pod silently picked up
whatever the session had configured - invisible at the call site and impossible to review. Model,
embedding and Vector Store settings still come from the running session (you proved those work by
asking the agent questions). Applied as overrides into split_config, never by mutating module
globals: a deploy must not leave the caller's session pointing elsewhere, and a test asserts it.
The target is validated against a live connection before anything is sent: catalog views
readable, business dictionary non-empty (an undocumented store deploys an agent that cannot
answer anything, so it is refused), and each read-only store checked with the federation attach
probe _validate_store - reused, not reimplemented.
Publication gate. The running version must be on PyPI, or a RuntimeError explains that the
image build runs pip install -r requirements.txt, that a local or editable install is invisible to
it, and the three ways out. require_published=False waives it for a private mirror; an unreachable
index stays fail-open.
analyze_pod_log (agentops/log_insights.py) turns a pod log
into findings, seeded with signatures actually observed: pip_unresolvable,
agent_runtime_missing, no_credentials, token_rejected, schema_missing, structured_output,
vector_store_auth, oom, and the positive startup_ok. The positive one is not decoration - "no
known failure matched" and "the agent started correctly" are different conclusions, and only the
second is evidence; without it a truncated log and a healthy one read the same. Logs are analysed
whether or not the deploy succeeded.
MCP is off by default and Enterprise-only when on. mcp_enabled=False omits exactly two intents
(DATA_QUERY, ALGORITHM); everything else works, so it is the right first deployment. When on,
the generated agent sets MCP_SERVER_HEADERS to the caller's Bearer token inside the turn lock -
build_mcp_headers resolves from module config only, and neither a static header nor Basic-from-a-
password can carry a per-caller token in a pod. No change to mcp_connection.py; mutating a module
global under the lock is the pattern _with_domain already uses. MCP settings are dropped from
config.yaml when MCP is off, so an archive never announces a server edition and tool cap it does
not use - nor carries an MCP credential it has no reason to.
agent_runtime no longer comes from a demo folder. AgentOps ships its runtime SDK as loose
files inside the tutorial directory and it is on no package index - the PyPI names agentops
(an agentops.ai observability SDK) and agent_runtime (a Microsoft AutoGen placeholder at 0.0.1)
are unrelated projects, and installing either gives a green build followed by an ImportError at
startup. Copying from Demo/ made an unversioned tutorial file an invisible input to every
deployment. The contract is two symbols and ~60 lines with no server or framework, so tdfs4ds now
emits its own implementation of the same interface when the destination has none
(agent_runtime='auto', the default). A platform-supplied copy always wins and is never
overwritten - its host may rely on details beyond the public surface. Both paths are fingerprinted
so "did the runtime change under me?" is answerable. agent_runtime='generate' forces ours;
'require' refuses to write. Twelve behavioural tests exercise the generated runtime through the
real contract (is_async for async and sync entrypoints, unchanged registration, double-register
refusal, AuthContext defaults/frozenness/get_auth_header/roles).
Fixed¶
- The entrypoint was registered wrapped.
AgentRuntimeApp.is_asyncisinspect.iscoroutinefunction(self._entrypoint), and decorators apply bottom-up, so@app.entrypointover@opik.trackregistered the opik wrapper - if that returns a plain function the platform drives an async agent synchronously. The entrypoint is now a bare coroutine with@app.entrypointas its only decorator, enforced by a test; tracing is left to the platform collector, since a span is not worth risking the runtime contract. - The generated agent now uses
AuthContext.get_auth_header()for MCP rather than formatting the Bearer by hand - the SDK's own docstring names the MCP server as its use case, which independently confirms the per-caller design. deployment_payloadsent_generated_at, which the API rejects outright (extra_forbidden), and omitteddocker_image, which the platform needs. Both covered by a test.check_agent_deploymentnow reports the resolved store (SCHEMA/SCHEMA_VIEW/DATA_DOMAIN), so "which database will the pod read?" is answered before deploying.
[0.3.1.172] - 2026-08-25¶
Added - JWT / per-caller identity for the AgentOps package, and token logon in reconnect¶
Found deploying to AI Studio: the environment authenticates users with a JWT
(create_context(host='dbc', logmech='JWT', logdata=f'token={user_token}')) and has no
username/password to give a service account. The v0.3.1.171 package assumed service identity, so
runtime_config came out empty and the pod deployed with no tdfs4ds configuration at all.
_connect_kwargs accepts a token logon (utils/reconnect.py):
TDFS4DS_TD_HOST + TDFS4DS_TD_LOGDATA (+ LOGMECH, defaulting to JWT) alongside the
historical host/username/password. Without LOGDATA the kwargs are byte-identical to before,
pinned by tests written as literals rather than as a comparison against the implementation. This
was not cosmetic: every automatic reconnect funnels through this function, so a JWT deployment
could previously open a context by hand but never recover one.
LOGDATA is a credential - 'LOGDATA' joins _SECRET_SUFFIXES, so export_config redacts it
and the AgentOps split routes it to runtime_config, never into the committed config.yaml.
auth='jwt' generates a different agent (generate_agentops_package(auth=...),
write_agentops_package(auth=...), check_agent_deployment(auth=...)). Three things follow from
the token model, none optional: there is no credential at import time (tokens arrive per
request), so the bootstrap becomes lazy on first invocation; the token expires, so the rebuild
is keyed on the token rather than done once; and identity is per caller, so the context is
rebuilt when the caller changes. That last one is only safe because turns are already serialized -
teradataml keeps ONE process-wide context, so interleaved turns would run under the wrong identity.
connect() still runs once per process: catalog views belong to the store, not the caller. Only a
fingerprint of the token is kept, never the token. auth='service' (the default) is unchanged
and produces byte-identical files.
check_agent_deployment(auth='jwt') requires only TDFS4DS_TD_HOST - demanding a password there
would fail a correct JWT deployment.
Fixed - deployment_payload sent fields the API rejects¶
Live POST /agents returned 422 extra_forbidden on _generated_at, a generation timestamp
the payload carried. Removed; the payload now contains only API-defined fields. It was also missing
docker_image, which the platform needs to know the base image. Both are covered by a test
asserting no _-prefixed key survives and that a base image is set.
[0.3.1.171] - 2026-08-24¶
Added - package the consumer agent for Teradata AI Studio (AgentOps)¶
tdfs4ds.agentops turns a working local session into a deployable AgentOps package, and then
verifies the deployed result. Five new entry points, all re-exported on tdfs4ds:
tdfs4ds.check_agent_deployment(dest='./teradata_agent') # before: is it deployable?
print(tdfs4ds.generate_agentops_package()) # inspect
result = tdfs4ds.write_agentops_package('./teradata_agent') # files + zip + runtime_config
payload = tdfs4ds.deployment_payload(defn_id, result['runtime_config'])
tdfs4ds.check_deployed_agent(agent_url, token=user_token) # after: does it work?
The package is derived from the running session, never hand-written. By the time a user runs
this they have already proved their configuration by asking the agent questions; the split between
what is safe to commit (config.yaml) and what must be injected at deploy time (runtime_config)
is computed from config.py's existing credential classification (_is_secret,
_CREDENTIAL_VARS, _PASSTHROUGH_ENV_VARS) rather than a second copy of it -- a duplicated
"is this a credential?" is what goes stale and leaks the thing it was written to protect.
Connection variables (TD_*, VECTOR_STORE_AUTH_*) are routed to runtime_config even when not
secret, because they identify a deployment: committing TD_HOST is how a package built against
dev keeps pointing at dev. SERVE_* is dropped (the platform is the server), as is CHROMA_* on
the Teradata backend.
Four structural mismatches with the platform, encoded in the generated code -- each invisible
until it fails in a pod. (1) The entrypoint is async and consumer_agent() is synchronous and
blocks on Teradata + LLM I/O, so it runs in asyncio.to_thread. (2) The platform calls its factory
per invocation, but connect() and the Vector Store handshake must happen once, so setup is
at module scope and the factory is a thin wrapper. (3) graph._with_domain mutates the
module-global DATA_DOMAIN and restores it, so two concurrent turns in one process clobber each
other -- an asyncio.Lock serializes turns, and throughput scales with pods. (4) The env mapping is
emitted before import tdfs4ds, because config.py populates module attributes exactly once at
import (the v0.3.1.155 trap); a fallback assigned afterwards is silently ignored.
thread_id never falls back to a constant. session_id if the caller sent one, else the
caller's identity, else a fresh uuid per call. A constant default would put every caller in one
shared conversation -- answers leaking between users, which reads as a working memory feature from
the outside. check_deployed_agent's session_isolation check asserts a fresh session sees no
trace of the previous one, precisely because a "does it respond?" test passes this bug happily.
grounded is the check only tdfs4ds can make. A pod whose Teradata connection failed still
answers -- fluently, and about nothing -- so the smoke test asserts a real name from
_known_catalog_names() appears in the response, distinguishing "the LLM replied" from "the agent
replied from the feature store".
pip_resolvable queries the index. A package built from an unreleased working tree installs
perfectly in the notebook and fails in the image build minutes later; the check reports it up front.
PyPI is treated as a default rather than an authority -- an unreachable index is fail-open, and a
private mirror can overrule the finding.
Documented limits, not defects (they follow from leaving agent/graph.py untouched):
replicas: 1 is mandatory, enable_memory: False, session memory is lost on pod restart, and Opik
tracing is one span per turn. Lifting the first three needs a checkpointer= passthrough; the
fourth needs callbacks=.
Also: the generated artifacts are pure ASCII, following scheduling/script.py -- they are
written and re-read across unknown default encodings. agent_runtime/ is the platform's and is
never generated, only preserved into the zip, which (unlike the platform tutorial's own zip step,
whose os.walk applies no filter despite the comment) really does exclude __pycache__/.pyc.
New skill fs-deploy-agentops, new docs page Deployment -> Teradata AI Studio (AgentOps),
47 offline tests in tests/test_agentops_package.py (no DB, no LLM, no network).
Changed¶
diagnostics._format_report/_print_reporttake an optionaltitle=, defaulting to the existing string, so the two new reports render through the one formatter instead of growing a second copy of it. No behaviour change for existing callers.
[0.3.1.170] - 2026-07-31¶
Fixed — the consumer agent crashed on its first step behind a relaying gateway¶
Every typed call died with::
ValidationError: 1 validation error for IntentResult
Invalid JSON: expected value at line 1 column 1
[input_value='```json\n{\n "intent": ...']
llm.with_structured_output(schema) defaults, in langchain_openai, to
method="json_schema" — OpenAI's Structured Outputs API. Its contract is that the server
guarantees a schema-valid reply via constrained decoding, so the SDK parses the content
verbatim (model_validate_json), with no tolerance for anything around it.
A gateway can accept response_format without honouring it. LiteLLM in front of Bedrock degrades
it to an instruction, and the model answers with markdown-fenced JSON. Column 1 is a backtick.
It is the gateway, not the model. This was first diagnosed per-model, on a report that Mistral worked where Anthropic failed. The raw content disproved it — both come back fenced from the same endpoint:
anthropic : '```json\n{\n "intent": ...t with high confidence.'
ministral : '```json\n{\n "intent": ...ull\n }\n ]\n}\n```'
A model-name heuristic would have fixed one deployment and missed the next. Nothing in the configuration states whether an endpoint enforces the schema — the only way to know is to ask it.
New agent/structured_output.py is the single seam all
nine call sites now go through (one classifier, one EXPLAIN, seven in the query optimizer — all
of which failed identically, so optimize_query=True was equally affected). It attempts the
default path unchanged, then falls back through two alternatives that are deliberately not
interchangeable — there is no single industry parameter for constrained decoding, and they fail for
different reasons:
| method | request | parsed | fails when |
|---|---|---|---|
| (default) | response_format={"type":"json_schema"} |
server-side, verbatim | the gateway relays without enforcing |
function_calling |
tools + tool_choice |
tool-call arguments | the route has no tool calling |
json_mode |
response_format={"type":"json_object"} |
client-side — JsonOutputParser, which strips markdown fences (verified) |
rarely; but no schema reaches the model, so field adherence rests on the prompt |
json_mode is last because it is the weakest guarantee, yet it is the most robust mechanism:
parsing happens in langchain, so it survives an endpoint that enforces nothing at all — precisely
the failure being handled. That is why both fallbacks exist rather than just the first.
Backward compatible in the strong sense: the fallback is reachable only after a call that
already raised, so a working deployment issues a byte-identical request — no method= at all, not
even an explicit "json_schema" — and never reaches it. A blanket switch to function_calling was
rejected: it changes the wire payload for every non-OpenAI deployment, and payload shape is
demonstrably gateway-sensitive here (v0.3.1.145 moved openai-compatible onto response_format
because vLLM-style guided_json was 422ing from a LiteLLM gateway).
The working method is remembered per process, so the extra round trip is paid once rather than on
every classification. tdfs4ds.STRUCTURED_OUTPUT_METHOD (env
TDFS4DS_STRUCTURED_OUTPUT_METHOD) pins it and skips the probe entirely.
include_raw=True was investigated as a gentler fix and does not work: in
BaseChatOpenAI.with_structured_output the response format is bound to the model and the wrapper is
RunnableMap(raw=llm) | parser_with_fallback, so the LLM runs first and the strict parse raises
inside _generate, before the fallback parser is reached.
Added — check_configuration now proves structured output, not just that the model replies¶
The instruct check gained a second stage, the same split as the MCP check (listing tools proves nothing about calling them): after the hello-world it asks for a small typed answer and reports whether the schema was actually enforced. A plain reply stays green on exactly the endpoint where the agent's first step dies, so the two are separate claims.
An endpoint that relays without enforcing is reported as ok with a hint — tdfs4ds handles it —
naming STRUCTURED_OUTPUT_METHOD and the method that actually worked here, so the wasted first
attempt can be avoided. The probe tries every fallback rather than stopping at the first success:
which mechanisms an endpoint supports is what tells you what to pin. Only an endpoint where no
path works is a failure. A model object exposing no with_structured_output is not probed rather
than failed: that is client plumbing, not an endpoint capability.
probe_json_mode.py (repo root, not packaged) is the standalone version for handing to a platform
team. Beyond the three langchain methods it also tries the parameter names the same idea goes by in
other stacks — guided_json (vLLM/SGLang), nvext.guided_json (NVIDIA NIM), grammar (HF TGI),
format (Ollama) — because a gateway generally accepts only its own, so the accept/reject pattern
identifies the engine behind it. Verified against api.mistral.ai: json_schema enforced,
json_object accepted, all four others rejected with 422.
[0.3.1.169] - 2026-07-31¶
Fixed — refreshing the vector index used a call that could never succeed¶
build_teradata_index's reuse path called only add_datasets(), which means attach a NEW
source object. tdfs4ds always names the same FS_V_BUSINESS_DICTIONARY_* view — the
documentation tables never change identity, only their rows do — so that call was guaranteed to
be refused, on every second and later build, with 400 "You can only add object names that do not
exist in the collection". Newly documented rows therefore never reached the index. 0.3.1.167 fixed
the reporting of that refusal; this fixes the cause.
The reuse path is now update(data_refresh=True) first, destroy + recreate as the fallback
(_resync_attached_collection):
update(data_refresh=True)is documented as "refresh/regenerate vectors … using existing backing/source tables" — exactly this situation. Cheap, no admin rights, collection preserved.- If it does not apply, the collection is destroyed and recreated from the same source. That needs admin rights and costs a full re-embed, which is why it is second — but it always picks the rows up, so an older service degrades instead of breaking.
add_datasets is no longer tried here at all: it cannot succeed against an always-identical
source by construction, and it is not free — ~50 s per store observed live before the 400 comes
back. It remains right for a genuinely new source, so its classifier
(_is_source_already_attached_error) is kept for collections whose source was attached outside
tdfs4ds.
0.3.1.140 recorded data_refresh as unreliable. That finding stands for the service version it
was made on and was never evidence about newer ones; a 2.2.17 service is reported to update
incrementally without trouble, and the fallback covers the case where it does not.
Whether unchanged rows get re-embedded is the service's business. Verified against the
installed teradatagenai, a hand-rolled per-row incremental is not safely expressible:
get_ids() raises NotImplementedError ("Currently this is not being supported by service"),
and delete_by_ids keys on TD_ID, which _inputdata recomputes as
ROW_NUMBER() OVER (ORDER BY <text>) — a position, not an identity, so a surgical delete can
leave _index's frozen embeddings aligned to different rows (the 0.3.1.140 corruption).
data_refresh is assumed to handle it, as the best mechanism the service exposes.
When neither mechanism applies the collection is left intact and searchable, and the warning names both failures — the second one alone reads as a rights problem and hides that the incremental path was refused first.
[0.3.1.168] - 2026-07-31¶
Added — selftest_vector_store(): prove the Vector Store actually works, and emit a support reproducer when it does not¶
Every Vector Store check up to now is read-only — service up, credential accepted, version
supported. None of that exercises what tdfs4ds needs: embedding rows and getting them back. And
every Vector Store failure this package has hit in the field is invisible to a read-only probe:
the ephemeral _inputdata view (0.3.1.140), update_failed collections written by a call that
raised nothing (0.3.1.139), add_datasets refusing an attached source (0.3.1.167). All three only
appear once something writes.
New vector_store_selftest.py, re-exported as
tdfs4ds.selftest_vector_store(), walks the whole lifecycle on a throwaway collection over a
throwaway table and records each step independently:
The step list is an audit of the API calls tdfs4ds actually makes — there are exactly seven
distinct ones in vector_store_teradata.py, and each gets a step:
| # | step | call exercised |
|---|---|---|
| 1 | service_version |
health() — version vs the supported minimum |
| 2 | create_source |
— (plain SQL) |
| 3 | create_collection |
from_datasets() + list(), and the collection really reaching ready |
| 4 | collection_details |
get_details() — the readiness probe every write waits on |
| 5 | search |
similarity_search() |
| 6 | parse_hits |
tdfs4ds's own _parse_one |
| 7 | update_source |
— (one sentence rewritten, coffee → cycling) |
| 8 | refresh_index |
update(data_refresh=True) and add_datasets() |
| 9 | search_after_update |
similarity_search() |
| 10 | major_finalize |
update(update_style='MAJOR') |
| 11-12 | cleanup_* |
destroy() (admin-only) and DROP TABLE |
Step 8 is the point. Resyncing an already-attached source is the one operation with no
reliably working mechanism: add_datasets answers 400 "You can only add object names that do not
exist in the collection", and Collection.update(data_refresh=True) worked once in testing then
failed consistently. Both are tried and the exact outcome of each is reported — so whether this is
a bug in a particular service version becomes a one-minute question answered on the system in
front of you, instead of an open investigation.
Step 6 is the one most easily left out, and the most valuable. _parse_one looks a hit's text
up under a fixed list of column names (_TEXT_KEYS), so "the service answered" and "tdfs4ds can
read the answer" are different claims — in 0.3.1.140 similarity_search returned eight real hits
while the public entry point returned zero rows, silently. Consequently the throwaway table mirrors
the real business-dictionary column shape (ID, DATABASE_NAME, OBJECT_NAME, OBJECT_TYPE,
BUSINESS_DESCRIPTION) and passes metadata_columns, rather than inventing convenient names: a
collection built over a column called TXT would return hits tdfs4ds parses to nothing while the
step still passed, i.e. a self-test unable to reproduce the very bug class it exists for.
Step 9 exists because "the refresh returned without raising" is not "the index changed". A silently stale collection is worse than a refusal, so it is reported as a failure.
On failure it writes a standalone reproducer (report["repro"]) for a support ticket: a single
.py using only teradataml/teradatagenai/langchain_teradata — never tdfs4ds, which support
will not have — truncated at the failing step, with the service version, client package versions
and the embedding configuration in the header. It reads every credential from environment
variables, and is forced to plain ASCII: the user writes it out with open(path, 'w') on a
machine whose encoding we do not control, so one em-dash echoed back from a server error would make
saving it raise UnicodeEncodeError — the artefact that exists to report a problem must not be
able to create one.
Contracts, each with a test: never raises (every step individually wrapped, the walk always
reaches cleanup); never leaks a secret (variable names and non-secret config only); cleans up
or names exactly what it left behind (destroy() is admin-only, so a failed cleanup is a
legitimate outcome and must not be silent); unique names per run (tdfs4ds_selftest_<8 hex>),
so a leftover from an earlier failed run can never collide with this one. A failure in one step
skips the dependent steps rather than cascading, because a derived error from a step that never
had a chance buries the real one.
Also wired into check_configuration(selftest=True) as a seventh check. It runs only when
asked — it is the only part of that function that writes — but the row is always reported,
saying so and naming the flag. Hiding it entirely (the first cut) made the feature undiscoverable:
a passing report gave the reader no way to learn that everything it had just verified was
read-only. Opt-in is about not writing unasked, not about hiding. The full per-step report and the
reproducer travel intact under the check's selftest key rather than being flattened to one line.
Added — per-check timings and progress, so a slow check is not mistaken for a hang¶
check_configuration() printed nothing until every check had finished, which on a slow service is
indistinguishable from a hang — and gave no way to tell which check was slow. Measured on a live
system: set_auth_token ~11 s, the first Vector Store REST call ~20 s cold (~3 s warm), and one
health() call observed at 83 s while the same call minutes later took 1.2 s.
Each check is now announced before it runs and logged with its outcome and duration when it
finishes, and every check row carries elapsed_s. The rendered report appends [N.Ns] to checks
taking a second or more — only those, since 0.0s against every instant check is noise that hides
the one that matters.
[0.3.1.167] - 2026-07-31¶
Fixed — build_vector_index reported a routine no-op as "service unreachable / check licensing"¶
Seen live: a second build_vector_index(force_rebuild=True) run died with "Teradata Vector Store
service is unreachable or not enabled on this system … Check that the Enterprise Vector Store
feature is licensed/activated" — while check_configuration() in the same notebook was green.
Both were right, and the message was wrong. The chain:
- The collection exists, so
from_datasets()raisesalready exists. Expected — that is the trigger for the reuse path. - The reuse path calls
add_datasets()with the sameFS_V_BUSINESS_DICTIONARY_*view that is already attached, and the service answers400 You can only add object names that do not exist in the collection— the KNOWN LIMITATION already written intobuild_teradata_index's own docstring. It is the expected steady state of every repeat call, not an edge case. _reraise_if_service_downthen matched the bare substring"response code:"and raised the licensing message — on a system whose Vector Store had just answered the request. The same misclassification shape as the SQLState 28000 fix in 0.3.1.164 and the 409 fix in 0.3.1.137: everyTeradataMlExceptionfrom this API embedsResponse Code:, so that substring can never distinguish a genuine gateway failure from an in-service one.
_is_source_already_attached_error now classifies it; _reraise_if_service_down returns for it
(terminal but benign — "nothing to add" — so the caller decides); and build_teradata_index logs a
warning naming the real remedy and continues to the remaining stores instead of aborting on the
first one. Deliberately kept distinct from _is_transient_staging_object_error: both are 400s
mentioning "not exist", but that one is retryable and this one is not, so conflating them would
make the retry wrapper spin.
check_configuration() passing was consistent, not contradictory. Its Vector Store check is
health() + list() — both read-only. This is a write-path-only condition. Same lesson as
0.3.1.164: ask what a check would still pass with the failure present.
Fixed — force_rebuild=True was a no-op on the Teradata backend¶
From 0.3.1.133 to 0.3.1.166 both branches of build_teradata_index ran identical code, so
force_rebuild was "purely informational". A user setting it — which they do precisely because the
index looks stale — got the same no-op, and once the source was attached, the same failure on the
retry. It now really destroys and recreates each collection (_destroy_for_force_rebuild), which is
currently the only working way to pick up business-dictionary rows documented since a collection
was created, and also the way out of a *_failed collection (so it runs before the failed-status
check, whose own prescribed remedy is this destroy + recreate).
destroy() is admin-only, so a refused destroy is a warning and a fall-through to the ordinary
reuse path, never a raise — a non-admin session ends up exactly where it was before, not newly
broken. force_rebuild=False still never destroys.
Added — the Vector Store SERVICE version is now checked¶
check_configuration()'s Vector Store check gained a third stage: the service version reported by
health(), compared against _VECTOR_STORE_MIN_VERSION = (2, 1, 11). An older service is up and
accepts the credential — stages 1 and 2 both pass — and only misbehaves once something writes, so
the version is the one place this is visible before a real call fails.
The hint says what the fix actually is: the Vector Store service is a server-side component of the
Vantage system, so upgrading it is a platform/admin action, not a pip install (and not a
licensing hunt) — with VECTOR_STORE_BACKEND = 'chroma' as the meanwhile workaround.
Evidence, not inference: 2.2.29 verified working at a customer site; a service reporting 2.0.5
was seen failing in the field. The boundary between them was not bisected, so a version in between
is untested rather than proven. Version parsing is fail-open — an unrecognised health() payload
shape is reported as "version not reported, could not verify" and never fails the check.
[0.3.1.166] - 2026-07-30¶
Fixed — the query optimizer could silently change business logic when flattening¶
The optimizer accepted a rewrite on two conditions: EXPLAIN compiles, and user_score does not
regress. Neither has anything to do with the query still meaning the same thing.
The alias mechanism. Simplification flattens nesting, which merges two previously-distinct name scopes. A bare column that resolved uniquely in the nested form can then either
- rebind to a different relation — EXPLAIN-clean, score unaffected, silently wrong; or
- become ambiguous (Error 3809), at which point the existing syntax-repair loop asks the LLM to fix it — and the LLM picks an alias with no information about which one was correct.
That repair path is the real vector: a coin flip gets written into the accepted SQL.
The fix is to remove the degree of freedom before the LLM sees the query. If a query compiles,
every bare column in it already resolves uniquely in its own scope — Teradata rejects ambiguity
outright — so the correct qualification is derivable, not a guess. New module
agent/sql_qualify.py: qualify_columns(sql, schema) resolves each
bare column against the relations visible in its scope and writes the binding down explicitly.
A later flatten then cannot rebind it, because the alias travels with the column.
Design constraints, all load-bearing:
- Never regenerates SQL. It only inserts
alias.at computed character offsets; every other byte is preserved. Parsing to an AST and re-emitting (e.g. via sqlglot) would mangleCURRENT VALIDTIME,QUALIFY,PERIOD/INTERVALand UAF constructs — the same class of silent corruption, from a new source. No new dependency was added. - Only qualifies what is provable. A prefix is written only when the name belongs to exactly one visible relation whose column list is fully known. Not found, found twice, or any relation in scope with an unknown column list → the token is left alone. Ambiguous names are reported, never guessed, and surfaced in the optimization report.
- Fail-open per scope, so the worst case is the previous behaviour.
- Schema comes from
HELP COLUMN db.obj.*(notDBC.ColumnsV, whoseColumnTypeis NULL for view columns — and process views are the common case).
Plan comparison was deliberately NOT used to validate it. It is the wrong oracle in both directions: a wrongly-bound column very often yields an identical plan (same joins, same spools), so it misses the error it was meant to catch, while estimated row counts drift for reasons unrelated to semantics and would cause false rejections. The qualified form is EXPLAIN-checked only as a compile guard against a bug in our own parser.
Added — structural invariant gate (agent/sql_invariants.py)¶
Qualification closes the rebinding hole; it does nothing about the other flatten failures, which
all compile cleanly. Worse, user_score is anti-correlated with safety: it is derived from the
count of [You] items, so a candidate that drops a predicate or a DISTINCT produces a simpler
plan, scores higher, and is accepted preferentially.
diff_invariants(original, candidate) compares a coarse structural fingerprint — source tables,
join flavours, predicate count, DISTINCT/QUALIFY, aggregation grain, window PARTITION BY,
aggregates, set operators. Only losses are blocking; doing more is a warning. Grain and
partitioning are compared alias-insensitively, because alias renaming is a legitimate rewrite
and comparing raw text would flag every one of them.
Wired into three acceptance points, before the score is consulted: skill_simplify_query's
accept branch, every initial candidate in skill_generate_candidates, and every refinement round
(compared against the original, so drift cannot accumulate across rounds).
Qualification is kept even when a simplification is rejected — it is a semantics-preserving hardening that makes every downstream strategy safer.
Live-verified (Vantage 20, 12/12)¶
HELP COLUMN on a table and a view; qualified SQL compiles and is row-identical to the
original (bidirectional EXCEPT = 0); the naive flatten of the unqualified form really does raise
Error 3809; the qualified form flattens cleanly and stays row-identical; and the wrong binding a
repair-loop guess would produce compiles fine while changing 4 rows.
One finding worth recording: the lossy rewrite the invariant gate blocked (dropped predicate +
narrowed PARTITION BY) returned identical rows on the fixture data — every provider had a
non-null specialty, and the two claims of one patient shared a drug code. So a row-comparison
EXCEPT probe would have passed it. An EXCEPT is only as complete as the data that happens to
exercise the difference; on a bounded FilterManager partition — exactly how a process runs — a lossy
rewrite can pass a row comparison and still be wrong on the next partition. The static structural
gate is complementary to a row-comparison oracle, not redundant with it.
Offline coverage: tests/test_sql_qualify.py, tests/test_sql_invariants.py, tests/test_optimizer_safety_wiring.py (63 tests, no DB, no LLM).
[0.3.1.165] - 2026-07-30¶
Fixed — run() crashed after writing its rows when no primary index was registered¶
Reported from a live Lake run over an ordinary permanent table:
File "tdfs4ds\feature_store\feature_data_processing.py", line 108, in generate_collect_stats
primary_index.sort()
AttributeError: 'NoneType' object has no attribute 'sort'
apply_collect_stats declares primary_index as Optional[str], _upload_features passes
through whatever the process catalog returned — None for a process registered without an
explicit primary index — and generate_collect_stats called .sort() on it unconditionally. The
'' defaults in its own signature never applied, because both arguments are always passed
explicitly. Passing primary_index= to upload_features did not avoid it: the value does not
survive the round-trip through the catalog.
Ordering makes this worse than a crash. COLLECT STATISTICS runs after ingestion, so the
feature rows were already written — a caller catching the AttributeError was left with correct
data and a process whose statistics were never collected, rather than a clean abort.
None and '' are now both normalised to "not specified" (previously '' reached the SQL as a
malformed COLUMN()), a bare string to a one-element list, and partitioning=None to '' — it
would have raised on .upper() four lines later for the same reason. Sorting switched to
sorted(): generating a query string was reordering the caller's own list as a side effect.
No signature change and no behavioural change for any call that currently succeeds.
[0.3.1.164] - 2026-07-30¶
Fixed — a rejected credential reported as "service not licensed"¶
Live on VCE/preprod: check_configuration() returned all-green while
rebuild_vector_indexing() failed with
Teradata Vector Store service is unreachable or not enabled on this system ...
Check that the Enterprise Vector Store feature is licensed/activated ...
Detail: (TDML_2412) Failed to execute collections_list. Response Code: 500,
Message: [SQLState 28000] The UserId, Password or Account is invalid.
Two independent defects, one visible symptom.
The message was wrong. SQLState 28000 is the SQL standard's invalid authorization
specification — the service answered and then the database refused the credential it
presented. _reraise_if_service_down classified it on "Response Code:" alone, so it read as
unreachable/unlicensed and sent the user after a feature flag. New _is_auth_rejected_error
matches the 28000 markers before the generic branch (the same position and reasoning as the
three existing transient pre-checks) and raises _AUTH_REJECTED_MSG, which names the
TDFS4DS_VECTOR_STORE_AUTH_* variables and the three real causes, expiry first.
The check could not have caught it. _check_vector_store_service called only
CollectionManager().health() — a service liveness endpoint that never opens a database
session, so it passes with a credential the database would reject. And check 1 cannot cover the
gap either: teradataml's JWT branch stores a ready-made token without validating it (the
validation call is commented out upstream), so set_auth_token() succeeds on an expired token.
Between them, nothing green had ever spent the credential.
The service check is now two-stage, mirroring the MCP check: health() for reachability,
then list() — what every real path starts with — for the credential end to end. Stage 1
passing while stage 2 fails is now itself the diagnosis. The JWT auth check states in its own
detail that the token is stored unvalidated, so a green auth line never again reads as a promise
the stage below can contradict.
Tests: the live 28000 string drives both the classifier and the report; a 404 still classifies as service-down, so the new branch narrows the old one rather than replacing it.
[0.3.1.163] - 2026-07-30¶
Added — two-phase tool discovery, as a fallback¶
.162 bounded the MCP tool set with MCP_MAX_TOOLS, but that leaves a guess: pick it too high
and the schemas still overflow, too low and the model loses tools it needed. This removes the
guess by recovering from the failure instead of trying to predict it.
When binding the tool schemas raises a context-window error, the model is shown a
one-line-per-tool index (name — description) — an order of magnitude smaller than the
schemas, so ~130 tools fit in a couple of thousand tokens where their schemas fit in none — asked
which tools the question needs, and only those are bound for one retry.
- Nothing is paid on a working path. The extra model call happens only after a failure, so a setup whose tools already fit behaves exactly as before — asserted by a test that counts selection calls on the happy path.
tdfs4ds.MCP_TOOL_DISCOVERY(defaultTrue) disables it to fail fast;MCP_FALLBACK_MAX_TOOLS(default8) bounds the retry, deliberately far belowMCP_MAX_TOOLS— the larger set is what just overflowed.- One retry, never a loop. A second overflow means even the focused set does not fit, and looping would only burn model calls.
- Never retried once output exists. An overflow on a later model call, after an answer or a chart has already been produced, re-raises rather than re-running and answering twice.
- Never on an unrelated error. A 401 surfaces as a 401.
is_context_window_errorrecognises how each gateway words it (LiteLLM/BedrockContextWindowExceededError, OpenAImaximum context length, Anthropicprompt is too long), including nested inside a genericBadRequestErrorbody. - An empty selection re-raises rather than binding nothing — an agent with no tools fails more confusingly than the original error.
- Selected names are verified against the real tool set, since a model asked for bare names will still number, quote or invent them.
Both the agent (_skill_mcp_tools_async) and check_configuration's MCP probe use the same path,
so a passing diagnostic exercises the agent's recovery too. Helpers live in the shared
agent/mcp_connection.py seam with the LLM injected, which
keeps that module free of any dependency on the agent and makes the whole path testable without a
model — see tests/test_mcp_fallback.py, which drives the real skill
with a stubbed client, agent and LLM.
Still one-shot in nature: the retry re-selects from the same question. It is recovery, not
mid-reasoning discovery — the model cannot reach for a tool it discovers it needs halfway through a
task. That would need dynamic tool binding (the langgraph-bigtool pattern) and is not attempted
here.
[0.3.1.162] - 2026-07-29¶
Fixed — the Enterprise MCP edition's ~132 tools overflowed the model's context¶
The first successful Enterprise connection listed 132 tools, several times what the Community edition exposes. Every tool's JSON schema is sent to the model on every turn, so the tool set is a context-window cost paid before the question is read — and the whole set did not fit:
BadRequestError: Error code: 400 - litellm.ContextWindowExceededError:
BedrockException: Context Window Error
This was not limited to the diagnostic. _skill_mcp_tools_async binds tools the same way
(create_react_agent(llm, tools, …)), so the consumer agent's MCP skills would have failed
identically against Enterprise. It is the first concrete consequence of the "different tool set"
difference MCP_SERVER_EDITION was introduced to describe.
select_tools(tools, question, max_tools=None) (agent/mcp_connection.py)
— the shared MCP seam both paths already import from — now bounds the tool set by
tdfs4ds.MCP_MAX_TOOLS (default 32, 0 disables, env TDFS4DS_MCP_MAX_TOOLS):
- At or below the cap the list is returned untouched, so a Community-edition install binds precisely the tools it always did. The cap engages only where the alternative is failing.
- Above it, relevance is a deterministic token overlap against each tool's name and description — no LLM, no extra round trip. Ties keep the server's ordering, and unmatched tools still fill the remaining slots, so an unforeseen phrasing degrades instead of emptying the toolbox.
- Tokenisation splits camelCase and matches crude plurals, because tool names are camelCase
compounds (
base_databaseList) while questions are English ("the databases"). Without both, every tool scores zero and the ranking silently collapses to "whatever the server listed first". Verified against the real Enterprise names with the useful tools deliberately listed last.
Fixed — a context-window error reported as "the model does not support tool calling"¶
That 400 mentions tools, so it matched the existing tool-calling hint and blamed the model's
capabilities — when the model never got the chance to try. A dedicated hint is now matched first,
naming MCP_MAX_TOOLS and stating plainly that this is not a tool-calling support problem.
[0.3.1.161] - 2026-07-29¶
Fixed — a trailing .env comment was being read as part of the value¶
_parse_dotenv skipped whole-line comments but never stripped a trailing one, so
authenticated with the literal Secret#123 # prod password. The damage surfaces far from
its cause — as a rejected credential from whatever service received it — and the value looks
correct in the file.
It now follows the python-dotenv rule, so a file means the same thing whichever loader
reads it (users routinely call load_dotenv() themselves before importing tdfs4ds): in an
unquoted value, whitespace followed by # starts a comment; a # with nothing before it
stays in the value (PASSWORD=abc#def → abc#def, which generated passwords rely on); and a
quoted value is verbatim, which is how to write a literal " #". Verified case-by-case
against python-dotenv in tests/test_export_config.py.
Fixed — Error 8017 reported as a URL problem¶
A credential rejected by Teradata comes back through MCP as [Error 8017] The UserId, Password
or Account is invalid — text carrying neither 401 nor auth, so it missed every entry in
_MCP_HINTS and got the generic "check MCP_SERVER_URL and MCP_SERVER_TRANSPORT" fallback. That
is actively misleading: reaching a database error proves the URL, the TLS and the
Authorization header all worked. There is now a dedicated hint saying the account is the only
thing wrong, and noting that a credential valid with logmech='TD2' can still fail if the MCP
server logs on with a different mechanism.
[0.3.1.160] - 2026-07-29¶
Fixed — a partially-created dataset catalog was permanently unrepairable¶
DatasetCatalog had two existence predicates that disagreed. _exists() reported True only when
all six objects (3 tables + 3 views) were present; create_catalog() refused — logger.error
plus a raised ValueError — as soon as any of them existed. Every partial catalog therefore
fell into the gap between them: the constructor sees "not all present", calls create_catalog(),
and that rejects "some present".
Because the constructor self-heals on every instantiation, this fired on ordinary read
paths (dataset_catalog(), the consumer agent's _resolve_dataset) and on connect()/setup()
— an ERROR-level log and an exception for a condition the caller could do nothing about. Observed
live with the three tables present and the views absent:
ERROR - The dataset catalog cannot be created because these tables already exist :
['DB.FS_FS_DATASET_CATALOG', 'DB.FS_FS_DATASET_ENTITY', 'DB.FS_FS_DATASET_FEATURES']
WARNING - _resolve_dataset: catalog query failed — …
create_catalog() is now idempotent: it creates the missing objects, leaves the existing ones
alone (tables before the views that read them) and returns the list of what it created. Both it
and _exists() read from one new _missing_objects(), so they can no longer disagree. This
matches the self-healing convention the rest of the package already follows
(documentation_business_dictionary_view_creation, time_manager_catalog_creation).
Also fixed, latent: create_catalog(schema_name=…) rebuilt the object names with a different
convention than the constructor — {name}_CATALOG and V_ instead of FS_{name}_CATALOG and
FS_V_ — producing a catalog no other part of tdfs4ds can find, since every other call site
spells it FS_V_{DATASET_CATALOG_NAME}_*. Both paths now derive names from one
_build_object_names(), and overriding the table database moves co-located views with it instead
of silently splitting the catalog. setup() no longer re-probes with _exists() after the
constructor already healed (one round-trip less), and logs what was actually created rather than
claiming "created" unconditionally.
Not a bug, for the record: the doubled FS_FS_ prefix is the intended convention —
DATASET_CATALOG_NAME is 'FS_DATASET' and DatasetCatalog adds its own FS_. All ~10 call
sites agree on it; renaming would orphan every existing store.
New DB-free tests in tests/test_dataset_catalog.py.
[0.3.1.159] - 2026-07-29¶
Fixed — three defects surfaced by the first full live check_configuration() run¶
Two of them were in the diagnostic itself, which is the worst place for them: a tool whose job is to name the setting to fix was blaming a correctly-set variable, and reporting a cause it had discarded.
1. config_loaded failed on legitimately falsy values. _unloaded_env_vars classified by
truthiness, so TDFS4DS_INSTRUCT_MODEL_VERIFY_SSL=false — which loads correctly as False —
was reported as "set in the environment but NOT applied", failing the whole report. The live run
contradicted itself: the instruct check printed TLS verification DISABLED in the same report,
which it can only do by reading that False. It also hit DISPLAY_LOGS=false (common),
DEBUG_MODE, USE_VOLATILE_TABLE, BUILD_DATASET_AT_UPLOAD, and EMBEDDING_BATCH_DELAY=0 /
SCHEDULING_MAX_WORKERS=0. Both classifiers now share _env_attr_status(), which compares
against the coerced expected value — what load_config itself applies — and yields
applied / unloaded / diverged. False == False is applied; only a genuinely empty
attribute (None, '', [], {}, missing) is unloaded; anything else is a divergence, still
reported and still non-fatal.
2. A custom httpx client silently stopped following redirects. openai's own
_DefaultHttpxClient sets follow_redirects=True; httpx defaults it to False, and a
supplied http_client is used verbatim (the SDK only overrides redirects per request for
endpoints that declare it, which chat completions does not). So building a client for any
reason — INSTRUCT_MODEL_PROXY, VERIFY_SSL=False, a CA bundle — turned redirect-following off
as a side effect, and an ingress redirect surfaced as a bare Error code: 302. Present for
proxy users since .158 wired that client into build_llm. _build_http_client now sets it,
the same line and the same reasoning as mcp_connection._ca_client_factory. Timeout needs no
such mirroring — the SDK compensates structurally, adopting its own 600 s default whenever the
supplied client is left at httpx's.
This also closes the 'str' object has no attribute 'model_dump' loop from .158: httpx
rewrites a 302 POST into a GET, so with following on the SDK lands on the redirect target
(typically an HTML login page → 200 + non-JSON → raw text → .model_dump() on a str) and
with it off the raw 302 appears. One ingress fault, two symptoms — now one hint, plus a
redirect probe that reports where the URL actually leads (GET … -> 302 -> https://…).
The probe sends no credential and strips the Location's query string, since an SSO
redirect carries tokens there.
3. ExceptionGroup hid every MCP stage-1 cause. An anyio task group stringifies as
unhandled errors in a TaskGroup (1 sub-exception) — a count, not a cause — so expired
certificate, 401, 404, wrong transport and DNS failure all collapsed into the same string and no
hint could match it. _short now flattens groups to their leaves (Type: message, recursively,
duck-typed so the exceptiongroup backport works too), fixing every except site at once. A
new hint covers transport mismatch (404/405/not acceptable/text/event-stream) and points
at MCP_SERVER_TRANSPORT.
Not a defect, for the record: an MCP failure is not caused by a failing instruct model.
Stage 1 (get_tools) involves no model, and stage 2 is skipped, never failed, without one —
the — N tool(s): suffix in the detail string is what distinguishes them.
[0.3.1.158] - 2026-07-28¶
Added — TLS control for the instruct model's HTTP calls¶
A LiteLLM gateway behind an internal certificate authority is only reachable with a custom httpx client:
tdfs4ds builds that client itself, so there was no way to express it — the deployment simply
could not use the endpoint.
INSTRUCT_MODEL_VERIFY_SSL(defaultTrue) —Falsedisables certificate verification.INSTRUCT_MODEL_CA_BUNDLE— the safer fix for the same problem: verification stays on, anchored to your internal CA's PEM (the patternMCP_SERVER_CA_BUNDLEestablished in.157). Prefer it when the certificate is obtainable.- Instruct-only by design. Embedding clients are untouched whatever the instruct model does, so a shared helper cannot leak one endpoint's TLS posture onto another.
- Backward compatible by construction: the existing
_proxy_http_clientalready returnedNonewhen nothing was configured, and that is exactly the guarantee wanted here. It is generalised (and renamed_build_http_client, old name kept as an alias) to build a client when any of proxy / verification / CA bundle applies — with proxy and TLS composing into one client rather than one discarding the other. With nothing set, nohttp_clientargument is passed at all, asserted on the realChatOpenAIkwargs. - Disabling verification is never silent: a warning names the variable and states that the
API key then travels over a connection whose peer was never authenticated.
check_configurationalso appendsTLS verification DISABLEDto the instruct check's detail, so an insecure-but- working setup is visible in the report rather than buried in config. - A malformed or missing CA bundle raises a
RuntimeErrornaming the variable instead of an opaque[X509] PEM liberror on the first request — and is never downgraded to default verification.
Added — a hint for the opaque 'str' object has no attribute 'model_dump'¶
Reported live. The traceback names langchain internals, but the actual event is that the
endpoint returned a success status with a non-JSON body: the OpenAI SDK returns response.text
verbatim when the content-type is not JSON (openai/_legacy_response.py), and langchain then
calls .model_dump() on a str. Neither a TLS nor a model problem, which is what makes it so
hard to place.
The new hint says so, and points at the two real causes: a wrong INSTRUCT_MODEL_URL path (the
SDK appends /chat/completions), or an ingress answering with a redirect, login or error page.
A companion hint covers genuine certificate-verification failures, pointing at
INSTRUCT_MODEL_CA_BUNDLE.
Fixed — build_llm never passed an HTTP client, so INSTRUCT_MODEL_PROXY never applied there¶
Found while verifying the above end-to-end: build_llm
(genai/documentation.py) constructs its own ChatOpenAI /
AzureChatOpenAI and passed no http_client at all. Only the consumer agent's _get_llm
ever injected one — so the documented outbound-proxy support silently did nothing for
documentation, the query optimizer, and check_configuration's instruct check, and the new
TLS settings would have missed exactly the paths that need them.
All three OpenAI-family branches in build_llm now inject the shared client (and its async
counterpart) when one is configured, and still pass nothing when none is — so the fix cannot
change behaviour for an install that configures neither. Regression-tested for both the proxy and
the TLS setting.
Offline tests: tests/test_http_client.py (19).
[0.3.1.157] - 2026-07-28¶
Added — two MCP editions, and authenticated access to Enterprise¶
There are two Teradata MCP products. The one tdfs4ds has talked to until now is the Community
Edition, which works because it embeds its own credentials — the connection was a bare
{"url", "transport"} dict with no way to send headers. Enterprise differs in two ways: it
requires caller-supplied HTTP Basic auth, and it exposes a different tool set. That makes
the edition a real distinction rather than an auth flag.
TDFS4DS_MCP_SERVER_URL=https://your-enterprise-mcp/one-td/mcp
TDFS4DS_MCP_SERVER_EDITION=enterprise
# -> Basic auth built from TDFS4DS_TD_USERNAME / TD_PASSWORD
MCP_SERVER_EDITION—'community'(default) or'enterprise'. Credentials default to the Teradata connection credentials: the Teradata MCP server authenticates with the database identity, so there is nothing to restate. Override withMCP_USERNAME/MCP_PASSWORDwhen the MCP account differs.MCP_SERVER_HEADERSsends verbatim headers for any other scheme (Bearer, API key) and wins over the edition;MCP_AUTH('none'/'basic') overrides the mechanism the edition implies.MCP_SERVER_CA_BUNDLEre-anchors TLS trust for an internal CA. Verification is never disabled — and a bad bundle raises aRuntimeErrornaming the variable rather than an opaque[X509] PEM libat connect time, or being silently downgraded to default verification.- New agent/mcp_connection.py is the single seam, used by the
agent and by
check_configuration, so a passing diagnostic describes the connection the agent actually makes. With none of the new variables set it returns exactly{"url", "transport"}— byte-identical to the dict it replaced. - Credential values are never logged or returned;
describe_mcp_auth()names the variable each part came from.MCP_PASSWORDandMCP_SERVER_HEADERSare credentials, soexport_configredacts them and JSON config rejects them.
Why the edition gates the credentials. TDFS4DS_TD_USERNAME/TD_PASSWORD are set in
essentially every deployment. Deriving Basic auth from them unconditionally would make every
existing Community install start sending the Teradata password to its already-configured MCP
endpoint on upgrade — a credential reaching somewhere it does not reach today, opted into by
nobody. Defaulting the edition to community makes the backward compatibility structural rather
than incidental.
Capability differences are exposed, not guessed. The edition is added to the ReAct system
prompt, and the MCP check lists the tool names it finds — that is how the CE/Enterprise delta
gets documented, from the live server. Nothing name-specific changed: _make_guarded_tools
guards on tool input text (name-agnostic) and chart_render._infer_kind already falls back
from its plot_* map to the tool description, so an unfamiliar Enterprise tool set degrades
rather than breaks.
Added — the MCP check runs in two stages, and takes your question¶
check_configuration() gains a sixth check, and it does more than ping:
- Reachability and auth —
get_tools(), reporting the tool count and names. - Can the model actually drive them? — a minimal
create_react_agentover the guarded MCP tools, asking by default for the list of available databases (both editions answer it). Override withcheck_configuration(mcp_question="…").
The split is the diagnosis: stage 1 failing means URL / auth / TLS; stage 1 passing while stage 2 fails means the model side — listing tools proves nothing about tool calling, and a model (or the gateway in front of it) can chat perfectly while never forwarding tool schemas. Stage 2 is skipped, not failed, when no instruct model is configured.
The probe is deliberately not routed through consumer_agent: that agent answers business
questions about the feature store, so a server-administration question would test its classifier
rather than the MCP tool path.
Offline tests: tests/test_mcp_connection.py (22) plus the MCP cases in tests/test_check_configuration.py.
[0.3.1.156] - 2026-07-28¶
Fixed — "package too old" was the wrong diagnosis; it is the wrong environment¶
The two teradatagenai hints added in .155 were built on an assumption that turned out to be
false. Checked against a real install: teradatagenai==20.0.0.7 ships
vector_store.collection and does accept api_type='td_hosted' — its own docstring lists
'litellm', 'td_hosted' and 'guardrails'. Yet a notebook reporting exactly that version via
!pip freeze failed with No module named 'teradatagenai.vector_store.collection' and an
api_type list missing all three.
Both symptoms have one cause: the interpreter importing the package is not the one pip was
inspected in. !pip resolves through PATH; the kernel imports from its own
sys.executable. Advising an upgrade sends the user round in a circle — the version they would
"upgrade to" is already installed, just not where they are running.
- New
_package_origin()reports where the package actually imports from and under which interpreter, and both checks now put that evidence in theirdetail. This is what distinguishes "too old" from "different environment" — two failures with identical tracebacks and opposite fixes. - Both hints rewritten to name the environment split, point at
%pip install(the%magic installs into the running kernel,!pipdoes not), and mention a shadowingteradatagenai.py/directory in the working directory.
Fixed — the litellm hint sent users to install packages they don't need¶
A live run hit cannot import name 'ChatLiteLLM' from 'langchain_community.chat_models' (recent
langchain-community releases have dropped it) and was told to install two packages. But that
deployment points INSTRUCT_MODEL_URL at a LiteLLM proxy — which speaks the OpenAI API, so
INSTRUCT_MODEL_PROVIDER='openai-compatible' reaches it through ChatOpenAI with no extra
packages at all.
- The hint now leads with that: proxy →
'openai-compatible', same URL/key/model. The package-install advice is kept for the other topology (the LiteLLM Python SDK in-process, no proxy, model strings like'bedrock/amazon.nova-pro-v1:0'). - Documented as a table in docs_src/configuration/llm-config.md — picking the wrong one of the two LiteLLM topologies is the common mistake.
The 'litellm' provider itself is unchanged and still supported.
Nothing else changed; .155's Config-load check and export_config() are unaffected.
[0.3.1.155] - 2026-07-28¶
Added — check_configuration() now catches the load-ordering trap¶
Found immediately in live use: check_configuration() reported the instruct and embedding
models as "not configured" and the backend as chroma, on a system whose environment plainly
set all of them — while the Vector Store auth check passed. The asymmetry is the explanation:
auth variables are pass-through and read live from os.environ, but everything else reads
tdfs4ds.<ATTR>, which config.py populates once, during import tdfs4ds. Anything
exported after that import (or a .env outside the working directory) never arrives.
Telling the user to "set INSTRUCT_MODEL_PROVIDER" when they had set it is exactly the
confusion this tool exists to remove.
- New
Config loadcheck, run first: compares everyTDFS4DS_*variable in the environment against the module config and fails when one is set but not applied, naming the variables and pointing attdfs4ds.load_config(). - It also reports variables whose value differs from the environment — without failing,
since an in-session override is legitimate. This catches the quiet half: an attribute sitting
at its default (
VECTOR_STORE_BACKEND='chroma') while the environment asks for something else is not "unset", so nothing else would notice. That is what silently skipped the Vector Store service check in the report above. - The instruct and embedding checks now say "…but
TDFS4DS_Xis set in the environment" and give theload_config()hint instead of blaming the user. - Names only, never values — the comparison covers credential variables too.
Added — tdfs4ds.export_config(): capture a working configuration¶
tdfs4ds.export_config() # inspect the text first
tdfs4ds.export_config('tdfs4ds.json') # reusable JSON config
tdfs4ds.export_config('.env', fmt='env', source='env') # env vars -> a file
source='module'(default) snapshots the running configuration, in-session changes included;source='env'converts theTDFS4DS_*environment variables into a file even when they never reached the module config — the trap above.fmt='json'writes whatload_config()reads;fmt='env'writesTDFS4DS_*lines and is the only format that can carry connection and Vector Store auth variables (they are pass-through, never module attributes).- Secrets are redacted by default, written as a commented placeholder so the result is a
shareable template;
include_secrets=Truewrites real values. JSON never carries credentials at all —_apply_filerejects them on read, so writing them would leak and not work. - Returns the text whether or not a path is given (inspect before writing, as
generate_execution_scriptdoes).
Fixed — three hints blamed a missing package for a version problem¶
All three surfaced in the same live run:
No module named 'teradatagenai.vector_store.collection'said "the package is missing" when the top-level package imports fine. A submoduleImportErroris now matched first. (The advice given here — "upgrade" — was itself wrong; corrected in.156.)No module named 'langchain_community'gave a generic list of LangChain integrations. It now names the actual requirement for thelitellmprovider:pip install langchain-community litellm— neither is a tdfs4ds dependency. The generic hint also maps each provider to its package.Invalid value(s) 'td_hosted' passed to argument 'api_type'was read as a missing-argument problem. It is not — the build being imported does not know thatapi_type. (Attributed to the version here; corrected to the environment split in.156.)
Fixed — secret detection redacted a harmless setting¶
_is_secret matched TOKEN as a substring, so EMBEDDING_MODEL_MAX_TOKENS (an integer) would
have been redacted out of exported .env files. Matching is now on the name suffix, plus
the explicit _CREDENTIAL_VARS set.
Offline tests: tests/test_export_config.py (16 new) and the extended tests/test_check_configuration.py.
[0.3.1.154] - 2026-07-28¶
Added — Vector Store auth accepts a token you already hold (JWT mechanism)¶
tdfs4ds could build three teradataml.set_auth_token(...) mechanisms (PAT, Basic, OAuth), all
of which make it generate a token. It had no way to express the case where the caller already
holds one — the normal shape in-cluster, where the platform injects an OAuth access token into
the pod:
tdml.set_auth_token(base_url='http://vectorstore.vectorstore.svc.cluster.local:8000',
auth_token=os.getenv('OAUTH_ACCESS_TOKEN'))
- New
TDFS4DS_VECTOR_STORE_AUTH_AUTH_TOKEN. In-cluster, map the platform's injected token onto it —TDFS4DS_VECTOR_STORE_AUTH_AUTH_TOKEN=$OAUTH_ACCESS_TOKEN. - Only
TDFS4DS_-prefixed variables are read. Reading the platform's generic name directly was considered and rejected: such a name can be owned by an unrelated component in the same environment, and picking it up would forward that component's credential to the Vector Store endpoint with nothing in the configuration naming it. The one-line mapping above keeps the intent explicit — and keeps everytdfs4dsinput inside its own namespace, so.envfiles behave the same way. - JWT is tried first, ahead of PAT → Basic → OAuth. Safe for every existing install (the variable is new, so nothing that resolves today changes), and it means an explicitly supplied token is never silently ignored in favour of a generated one.
- The token value is never logged — only the
base_urland the name of the variable it came from, so a wrong-token diagnosis stays possible without leaking it. resolve_auth_mechanism()/configured_auth_variables()split out ofconfigure_vector_store_auth(): one definition of the mechanism table, now also readable without side effects (no token generation, no network) — which is whatcheck_configuration(live=False)uses._NOT_CONFIGURED_MSG(the error a user actually hits) lists the new group too.
Added — tdfs4ds.check_configuration(): validate the setup, and say how to fix it¶
Nothing answered "is my AI configuration actually usable?" until something failed deep inside
build_vector_index() or a documentation run, with an error naming the symptom rather than the
missing setting. New module tdfs4ds/diagnostics.py:
tdfs4ds.check_configuration() # live: real handshake, one prompt, one embedding
tdfs4ds.check_configuration(live=False) # resolve + build clients only, call nothing
Four checks, in dependency order:
| Check | Applies when | What it does |
|---|---|---|
| Vector Store auth | backend is teradata, or any auth variable is set |
resolves and applies set_auth_token, reports the mechanism |
| Vector Store service | backend is teradata |
CollectionManager().health() |
| Instruct model | provider + model set | build_llm(), then a hello-world prompt |
| Embedding model | always | branches on the backend (below) |
The embedding check deliberately differs per backend: with the Teradata Vector Store and
TERADATA_AI_API_TYPE set it verifies only that TeradataAI(...) instantiates — in-database
embedding needs nothing further from the client; with ChromaDB (or Teradata without
TERADATA_AI_API_TYPE) it embeds a hello-world string and checks the vector length against
EMBEDDING_MODEL_DIM, because a mismatch there never raises — it silently builds an index that
cannot be searched.
- Every non-passing check carries a
hintnaming the setting to change — matched from the error text (401 → which API key; 404 →list_instruct_models(); 422 on structured output → the LiteLLM/Mistral provider gotcha; 422 "Input should be a valid string" → the tiktoken-array bug; 502/503/504 → transient, retry; and so on), with a per-check fallback so an unrecognised failure still gets direction. Askippedcheck hints too, naming the variable to set. - Never raises and never leaks. Each check is wrapped individually; only variable names,
URLs and model names reach the output — never a token, key or password. A legacy Windows
console that cannot encode the emoji gets ASCII markers rather than a
UnicodeEncodeError. - Reuses the real code paths (
build_llm,get_embeddings,get_vector_store_embedding,configure_vector_store_auth), so a passing check means the actual path works. - The Teradata database connection is not checked — it is external to the platform and owned
by the caller (in-cluster, typically
tdml.create_context(host=…, logmech='JWT', logdata=…)). - Surfaced in the admin Parameters panel as a Configuration check card (
GET /ui/params/check,panels.check_configuration), rendering the same results and hints.
Offline tests: tests/test_check_configuration.py (26) and the extended tests/test_vector_store_auth.py (15).
[0.3.1.153] - 2026-07-27¶
Added — run_now=False now validates the source with EXPLAIN before registering¶
run_now=False (added in .152) skipped the run — and the run is what normally proves the
feature source actually executes, so a lazily-declared process could bank an invalid source and
only fail much later, at its first roll. It now EXPLAINs first.
- The check runs before any catalog row is written, so an invalid source never leaves a
registered process behind. On failure nothing is registered: a prescriptive message is
logged and
Noneis returned, no traceback — consistent with the other recoverable paths. - Closes a real gap:
_resolve_feature_sourcealready EXPLAINs thesql_queryform (the query, thenSELECT * FROMthe created view) and the'schema.view'form. It has never EXPLAINed the teradataml DataFrame form — which is also the form most likely to carry a fragile ephemeral view (thecrystallize_viewcase). That path is now covered. - UAF ART views: only the FIRST
art_sqlstatement is EXPLAINed, and the ART is NOT materialised.art_sqlorder is authoritative and a later statement may read an ART an earlier one creates — so without materialising, statements 2..n would fail for a reason that says nothing about their own validity. The feature view is skipped for the same reason (it reads the ART → Error 3807), matching the EXPLAIN skipdocument_process_incrementalalready applies to this process type. - EXPLAIN is not a guarantee, and the docstring says so. It proves the statement compiles
and its objects are reachable. It does not prove ingestion:
run()additionally doesGettdtypes/validate_feature_typesand rejects NULL entity keys not covered byentity_null_substitute, and the real ingestion statement is an unpivot CTAS, notSELECT *. A lazily-declared process can EXPLAIN clean and still fail its first run.
Compat: no signature change. No parameter added, removed or reordered; run_now keeps its
True default, so every run_now=True caller is completely unaffected (no extra round-trip
is issued on that path — running already proves the source). The behaviour change is confined to
run_now=False, which was introduced in the immediately preceding, unreleased .152: it can now
return None where it would previously have registered an invalid process. That is the intended
fix, not a regression.
[0.3.1.152] - 2026-07-27¶
Fixed — upload_tdstone2_scores could never run (register_entity arity)¶
upload_tdstone2_scores(model)calledregister_entity(entity_id)with one argument, butregister_entity(entity_id, feature_types, ...)requires two — so the function raisedTypeError: register_entity() missing 1 required positional argument: 'feature_types'on every call. Confirmed by signature binding.- Fixed by hoisting
feature_names_types = tdstone2_Gettdtypes(model, entity_id)above the call and passing it:register_entity(entity_id, feature_names_types). The hoist is safe —tdstone2_Gettdtypesreads only the model's own predictions/computed features, never the feature-store tablesregister_entitycreates.register_entitystill runs beforeregister_features, as it must. - Compat: cannot break any caller. The function had no working behaviour to depend on —
it raised
TypeErrorbefore touching the database. Reported by the tdquiver session.
Added — upload_features(..., run_now=False)¶
upload_featuresgained an optionalrun_nowkeyword (defaultTrue). Withrun_now=Falsethe process is registered without being executed: the process-catalog row, data-distribution row and any filtermanager row are written and the view is created, butrun()is not called and no feature values are computed. Pair it withreturn_process_id=Truefor a lazy declare-now/compute-later flow:
process_id = tdfs4ds.upload_features(..., run_now=False, return_process_id=True)
tdfs4ds.roll_out([process_id], time_manager=tm) # drives every vintage, including the first
- Caveat:
register_entity/register_featuresand theFS_T_*table creation happen insiderun(), so until the first run the feature catalog has no entry for these features and their storage tables do not exist —get_feature_versionsreturns nothing for them. That is the intended lazy state, not an error. Dataset documentation is skipped too (the dataset view does not exist yet). - Compat: fully additive.
run_nowwas appended to the end of the signature (afterreturn_process_id) — no existing parameter moved, so no positional shifts — and it defaults toTrue= register and run, exactly as before.
[0.3.1.151] - 2026-07-27¶
Added — upload_features(..., return_process_id=True)¶
upload_featuresgained an optionalreturn_process_idkeyword (defaultFalse). WhenTrueit returns thePROCESS_ID(a UUID string) of the process it just registered, instead of the dataset — so a caller can hold the id for a laterrun(process_id)/roll_out([process_id], …)without a process-catalog lookup:
- Mirrors the existing
run(return_dataset=…)idiom: the flag toggles what is returned, it does not wrap the result in a tuple. The default return (dataset) is alreadyNoneunlesstdfs4ds.BUILD_DATASET_AT_UPLOADisTrue, so in the common case nothing is given up by setting it. - The recoverable input-error paths (missing schema, target view already exists, SQL that
will not compile) still return
Noneregardless of the flag, with a prescriptive message logged and no traceback — aNonereturn still means "check the log".
Backward compatibility: fully additive. The parameter was appended to the end of the
signature — no existing parameter moved, so no positional argument shifts — and it defaults to
False, so every existing caller returns exactly what it returned before. No existing
signature, default, or return value changed. (Note: "appended", not "last" — 0.3.1.152
later appended run_now after it. The guarantee is that nothing before it moved, which holds
however many kwargs are appended afterwards.)
[0.3.1.150] - 2026-07-16¶
Added — cross-store MIGRATION / promotion (lab → production)¶
Where .148 attaches other stores read-only, this imports selected work from a distant
(lab) store into the primary (production) store. Two governed phases, both re-exported on
tdfs4ds.
plan_migration(seeds, source, *, all_domains=True, target_data_domain=None, data_strategy='recompute', attach=True)(migration/planner.py) — READ-ONLY, never writes. Attachessourceread-only (§.148), resolves a mixed process / dataset / feature seed list in the source store, and expands the upstream process closure — the same self-contained unitanalyze_parallel_plancomputes, reusing the parallel planner's pure graph core (_resolve_seeds/_build_process_dag/_upstream_closure/_compute_waves) rather than reimplementing it; only the catalog reads are re-scoped to the source. Walks each closure view's DDL to inventory and classify every object (process_view/dataset/intermediate_view/source_table), detects conflicts against the primary (feature-name triplet collisions, object-name collisions), inventories the filter/time managers (content-hash equivalence → unify vs recreate), and returns a JSON manifest that requires approval.- The lab→prod downstream-safety gate. For each object proposed for
replace,downstream_feature_losswalks the primary's descendants (real production consumers) and marks the conflictblockingwhen a dependent needs a feature the incoming version drops. Dataset dependents are checked against their exposed feature set; process/plain-view dependents are treated conservatively as needing everything. migrate_objects(plan, approvals=None, *, dry_run=False, rebuild_vector_index=True, progress=None)(migration/executor.py) — the only writer. Refuses to run unlessvalidate_approvalspasses: every conflict decidedskip/replace, and no approvedreplacestillblocking. Executes wave by wave, upstream-first — recreate upstream non-FS objects, migrate managers, re-register each process with a fresh primaryPROCESS_ID(asource_pid → primary_pidmap threaded throughout), bring in data (defaultrecompute=run(primary_pid)), rebuild datasets, re-home the business dictionary, re-derive lineage, rebuild the vector index.- Writes resolve PRIMARY-only. The executor temporarily detaches read-only stores
(
SCHEMA_READONLY=[]) for the duration: the planner attached the source, which would otherwise leak the source's feature-table location intobuild_dataset. It reads the source through explicit schema names, never federation, so detaching is safe (restored in afinally). - Datasets are rebuilt, not copied. A dataset view carries store-local
FEATURE_ID/FEATURE_VERSIONreferences, so it is regenerated viabuild_datasetagainst the primary's freshly registered features with source→primary process-id remapping — a blind view copy would carry identifiers meaning something else in the target. - The linchpin is a pure DB/name rewrite (migration/rewrite.py):
rewrite_referencesswaps fully-qualifiedsrc_db.src_obj→dst_db.dst_obj(quote/case tolerant, qualified-only so bare column names are safe),remap_process_idsrewritesFEATURE_VERSION='<uuid>'dataset references,build_placement_mapputs tables → primarySCHEMA, views → primaryview_schema(). render_migration_report(plan, fmt='markdown'|'html')is the approval report;generate_migration_script/write_migration_scriptemit a standalone.pythat re-plans and executes with a baked-in approval map. Every auxiliary step (BD / lineage / vector) is fail-open. Admin "Migration" panel: source picker → seeds → Build plan → per-conflict approval controls (replace disabled when the gate marks it blocking) → staged SSE run + script download.- Live-verified end-to-end on a demo cluster (
DEMO_USER→ throwawayDEMO_MIG): process migration with source-table copy and view-reference rewriting, re-registration with a fresh pid,run()recompute of 8 features, dataset migration (ds_provider_statsrebuilt — view, catalog rows and 200 correct rows), re-run in the migrated store, idempotency on re-plan, approval enforcement, and the blocking gate firing against real lineage. Source untouched. - Known gap: the
copydata path is a documented stub — onlyrecomputehas been exercised._copy_feature_valuesstill needs theFEATURE_ID-remappingINSERT…SELECT. - Pure helpers unit-tested without a DB: test_migration_rewrite.py, test_migration_conflicts.py, test_migration_planner.py.
Trial-cluster gotcha: a freshly
CREATE DATABASE'd database can transiently read as "does not exist" in the next session — do create + setup + migrate in one session.
[0.3.1.149] - 2026-07-15¶
Fixed — parallel-plan write conflicts on a store whose features are not yet registered¶
A MERGE locks its target FS_T_* table, so two processes may run in parallel only if they write
different tables — and physical tables are keyed by (data_domain, entity, feature_type).
_pid_to_tables resolved that from the feature catalog, which on a fresh parallel build is
still empty: the exact table could not be resolved, no conflict was detected, and same-entity
processes were free to race the same MERGE.
- When a process's features are not yet registered,
_pid_to_tablesnow substitutes a synthetic(DATA_DOMAIN, ENTITY_ID)conflict key (_entity_conflict_key, sentinel database__ENTITY__, renderedentity:DOMAIN::ENTITY), so same-domain/same-entity processes serialize instead of racing. - The fallback fires only when the exact table set is empty. Once features are registered the exact table drives the decision again, so same-entity but different-data-type processes keep their parallelism.
- Accepted rare gap: a registered process (exact table) and an unregistered one (synthetic key) writing the same physical table are not matched.
Added — opt-in cap on parallel workers¶
max_workersongenerate_execution_script/write_execution_scriptcaps the number of units run in parallel per wave via a singleProcessPoolExecutor(max_workers=…). A wide wave's extra units queue in the rolling pool — each starts the instant a worker frees; they are not split into synchronized sub-waves, and the only barrier remains the one between waves. Aserialize_togethergroup counts as one unit (its processes run back-to-back inside a single worker), so the cap bounds concurrent units while same-tableMERGEs never overlap.tdfs4ds.SCHEDULING_MAX_WORKERS(envTDFS4DS_SCHEDULING_MAX_WORKERS/ JSONscheduling_max_workers, nullable int) is the global default; an explicitmax_workers=argument overrides it for one call. When both areNonethe generated script is unbounded — one worker per unit, byte-identical to the historical behaviour.- Surfaced in the admin "Execution plan" panel as a Max parallel workers field →
&max_workers=on the/ui/parallel/scriptdownload route.
[0.3.1.148] - 2026-07-14¶
Added — cross-store read federation (read-only extended connections)¶
tdfs4ds.SCHEMA_READONLY(default[]) +extend_read_stores(databases)/detach_read_stores(databases=None)/list_read_stores()andconnect(..., databases_readonly=[...])(alsoTDFS4DS_SCHEMA_READONLYenv / JSONschema_readonly) — connect to a primary store read-write as usual and extend the session to one or more other feature stores read-only. Each store is a string ('DB_B') or a dict descriptor{'schema','schema_view','vector_prefix'}.- Reads federate, writes don't. When read-only stores are attached, catalog / business-dictionary
/ lineage reads become a
UNION ALLacross the primary + read-only stores (each row tagged with a syntheticFEATURE_STOREprovenance column, primary first), andbuild_datasetcan pull a read-only store's features into a dataset built in the primary.upload_features/ documentation / lineage writes still target onlytdfs4ds.SCHEMA. - Byte-identical default.
SCHEMA_READONLY == []⇒ every read funnel keeps its historical single-store form (the union builders returnNone) — no behavioural change unless a read-only store is attached. Same opt-in shape as thedatabase_viewsplit. - Teradata vector search federates live —
search_teradata_indexsearches each store's own materialized collections by(vector_prefix, target_database)and merges hits (no re-index; a store without collections is skipped fail-open, still answerable via catalog/BD paths). Chroma (single local index) does not federate — out of scope. - New module
tdfs4ds/read_federation.py(attach/validate + the sharedcatalog_union_sql/federated_*builders) and accessorsread_view_schemas()/read_table_schemas()/read_stores(). Attach validation rejects a version-skewed / ungranted store (fail-open); optionalhas_bd/has_lineagecapabilities are probed so a store without docs or lineage never black-holes those unions. - Documented collision limit: the consumer agent's identity/counting/domain-resolution is
store-blind — exact for stores with disjoint domains/entities/feature names (the common case);
a same-
(domain, entity, feature)-across-stores clash resolves to the primary. Full provenance-in-identity hardening is a named follow-up. - Offline unit tests: tests/test_read_federation.py.
[0.3.1.147] - 2026-07-13¶
Added — parallel-execution planner, FS_TIME_MANAGER, "Execution plan" admin panel¶
tdfs4ds.analyze_parallel_plan(seeds=None, all_domains=True)— seed a list of processes / datasets / features, resolve each to its producing process(es), expand to the upstream process closure (everything needed to (re)build them — a self-contained migration unit), and group the closure into dependency waves: a topological layering of the process DAG (derived from the persistedFS_LINEAGE_GRAPH) where every process in a wave is mutually independent and can run in parallel, and waves run in order.seeds=Noneplans the whole store. The result also reports per-wave manager groups,unify_candidates(equivalent but distinct FilterManager/TimeManager objects), aserialize_togetherwrite-conflict guard, and processes with unknown lineage.tdfs4ds.generate_execution_script(plan, mode='process_pool', ...)/write_execution_script(plan)— turn a plan into a standalone.pythat runs waves sequentially and processes within a wave in parallel via separate OS processes (each worker opens its own Teradata context fromTDFS4DS_TD_*env — safe against the module-global and shared-connection-pool hazards that make in-process threading unsafe).tdfs4ds.unify_filter_managers/unify_time_managers(opt-in,dry_run=Trueby default) — repoint parallel-candidate processes onto one canonical manager (rewrite the process view's manager reference + update the catalog row).- New
FS_TIME_MANAGERcatalog — a per-process TimeManager association mirroringFS_FILTER_MANAGER, exposed asTIME_DATABASE_NAME/TIME_VIEW_NAME/TIME_TABLE_NAMEonFS_V_PROCESS_CATALOG. It is populated byroll_out— asroll_out(process_list, time_manager, …)runs it records each process's TimeManager (idempotent, non-fatal). A TimeManager is a roll-out driver, not a registration-time property, soupload_featuresis unchanged (notimemanager=argument; registration SQL is byte-identical). Fully backward compatible and self-populating:connect()creates the (empty) table on pre-existing installs before refreshing the catalog view, and simply running your existing rollout fills it in — no migration step, including for processes registered by older versions. - Admin "Execution plan" panel — pick datasets/processes, see an interactive wave-layered
workflow graph (what runs in parallel vs sequentially), and download the generated
.py.
[0.3.1.146] - 2026-07-13¶
Added — database_view: optional separate database for views vs tables¶
- New opt-in configuration for environments that keep views in one database and tables in
another (governance / access-control policy).
setup(database, database_view=...)andconnect(database=..., database_view=...)accept a second database dedicated to VIEW objects, exposed as the globaltdfs4ds.SCHEMA_VIEW(also settable via theTDFS4DS_SCHEMA_VIEWenvironment variable /.env, or theschema_viewJSON config key — same tier asSCHEMA). - Fully backward compatible.
database_view=None(the default) makesSCHEMA_VIEWresolve toSCHEMAthrough the newtdfs4ds.view_schema()accessor (SCHEMA_VIEW or SCHEMA), so every generated statement is byte-identical to before — no behavioural change unless a view database is explicitly configured. - When set: every physical TABLE stays in
SCHEMA(featureFS_T_*tables, all catalog/_HIDDEN, business-dictionary, and lineage base tables) and every VIEW — process views, dataset views, all frameworkFS_V_*public views, per-feature value views, and the business-dictionary / lineage / process-catalog / dataset-catalog views — is created and read inSCHEMA_VIEW. Implementation: table sites keeptdfs4ds.SCHEMA; view create/read sites resolve throughtdfs4ds.view_schema(). A view'sFROMthat reads a table keepsSCHEMA(tables never move); only a view-over-viewFROMresolves to the view database. - Unqualified names now default to the view database. Registering a process/dataset with an
unqualified name (e.g.
upload_features(..., target_view_name='my_view')) places the view inSCHEMA_VIEWautomatically (relaxing the previous hard "must be schema-qualified" rejection); an explicitdb.viewis still honoured. Covers_resolve_feature_source,build_dataset, andget_process_id. FEATURE_DATABASE(feature catalog) stays the table database so cross-database dataset views resolve their"{FEATURE_DATABASE}"."{FEATURE_TABLE}"sources;DATASET_DATABASErecords the dataset view database.DatasetCataloggainedschema_name_view/existing_view_objects.- Operational precondition (not enforced in code): the view database must be granted
SELECT(andWITH GRANT OPTIONto expose views to downstream consumers) on the table database. - Migration for an existing single-database store: set
database_viewand callconnect(create_if_missing=True)to (re)create the framework views in the new database. Stale views left behind in the old database are not dropped automatically. - Out of scope (unchanged):
TimeManager/FilterManagerbacking objects (placed by their own explicitschema_name) and the Enterprise Vector Store collection's owntarget_database.
[0.3.1.145] - 2026-07-13¶
Fixed — openai-compatible provider now uses response_format, not vLLM guided_json (LiteLLM / Mistral gateways)¶
- Bug: documenting/scoring a SQL query through an OpenAI-compatible gateway that fronts a
non-vLLM backend (e.g. a LiteLLM proxy → Mistral) failed with
UnprocessableEntityError: 422 … "loc":["body","guided_json"], "msg":"Extra inputs are not permitted". Root cause: theopenai-compatible(andvllm) provider path sent vLLM'sguided_jsonstructured-output parameter — a vLLM-only extension, not part of the OpenAI API — which such gateways reject. A provider literally named "openai-compatible" must speak the OpenAI API, i.e. useresponse_format. - Fix:
openai-compatibleis reclassified into theresponse_formatjson_schema group (alongsideopenai/azure/mistralai;strictis set only foropenai/azure, which Mistral and generic gateways reject).vllmis unchanged — a real vLLM server still getsguided_json. Use providervllmfor a raw vLLM server,openai-compatiblefor any OpenAI-protocol gateway. - Centralized the provider → structured-output decision (mechanism and schema wrapping)
into a single source of truth,
_build_provider_llm_caller(llm, provider, base_schema, schema_name)in genai/documentation.py. The scattered per-site provider branching (build_documentation_json_schema,build_sql_documentation_chain,build_explain_documentation_chain, and the inline dataset path) — the reason the branches had drifted — now all route through it. - One-shot fallback: if a backend rejects the structured-output parameter
(
_is_structured_output_rejection— matches the 4xxextra_forbidden/ "Extra inputs are not permitted" / "unknown|unsupported field/parameter" shapes namingguided_json/response_format/format), the caller retries once unconstrained and relies on the fence-tolerant_robust_json_parser. A genuine model error (context length, rate limit, content policy) never names those parameters, so it is not swallowed. This makes any gateway work even if it supports neither mechanism, so this class of bug cannot recur. - No config change required for existing users; no behaviour change for
vllm,openai,azure,bedrock,gcp,litellm, orollama. Immediate workaround for anyone already hitting this on a Mistral-backed gateway: setINSTRUCT_MODEL_PROVIDER='mistralai'(or now'openai-compatible'). - Tests: tests/test_structured_output_provider.py (pure unit, no Teradata/network) — mechanism selection per provider, the rejection detector against the real ticket message, and the one-shot fallback (incl. that unrelated errors are not swallowed).
[0.3.1.144] - 2026-07-12¶
Added — OFS backing-table storage for TimeManager / FilterManager (Vantage Cloud Lake + tdstone2 Apply)¶
- Opt-in
storage=constructor kwarg on bothTimeManagerandFilterManager(time_management.py, filter_management.py):None(default) = system default (block storage),'OFS'= Object File System (MAP = TD_MAP2, STORAGE = TD_OFSSTORAGE). A companionofs_map=kwarg (default'TD_MAP2') overrides the map. The choice is per manager, never a global — a user can run block-storage (full primary cluster) processes and OFS/Apply processes side by side in the same session. - Context: on VCL, a tdstone2 model scored via the Apply backend runs on the
Analytical Compute Cluster (ACC), which reads from OFS.
storage='OFS'lets a manager's backing table live on OFS instead of block storage. - Making a manager-referencing view score on the ACC needs BOTH (confirmed live on VCL2):
(1) the manager table on OFS (
storage='OFS') so the ACC can read it — a block-storage read raises Error 6881; and (2) the filter written as a JOIN to the manager view, not a correlated scalar subquery — the ACC rejects... WHERE d <= (SELECT BUSINESS_DATE FROM <tm_view>)with 6881 but accepts a JOIN. Verified: OFS + JOIN scores (~114s); OFS + scalar subquery still 6881s. Sostorage='OFS'is necessary but not sufficient on its own — it is genuinely part of the working recipe (the ACC reads the manager table through the JOIN), and the JOIN filter shape is the user's responsibility (a property of the scoring view's SQL). A JOIN keeps the reference dynamic (time-travel viaupdate()still works); a hard-coded literal also passes but freezes the date. No literal-inlining helper orroll_outintegration is added — the query shape is authored by the user. roll_out/run()still cannot drive a tdstone2-Apply model — its ingestion CTAS (CREATE VOLATILE TABLE AS SELECT * FROM <live scoring view>) also hits 6881; callmodel.score()first, thenupload_features.- New helper module utils/storage.py:
resolve_storage,storage_clause(storage, ofs_map)(returns theCREATE TABLEoptions fragment —''for the default so DDL is byte-identical to before, or, MAP = …, STORAGE = TD_OFSSTORAGEfor OFS),is_acc_ineligible_error, andACC_6881_HINT. The clause is injected immediately after the table name in the 5 backing-table CREATE sites (TimeManagerload_time_steps/clone_timerhard /take_ownership; FilterManagerload_filter/clone_filterhard). - Clearer 6881:
_compute_features_batch(execution.py) now logsACC_6881_HINT(still re-raises the original) when its own ingestion CTAS hits a 6881. - Docs: the OFS + JOIN Apply pattern (and the score()-then-
upload_featuressequencing) is documented across thetime-manager/filter-manageruser guides, thefs-rollout/fs-filterskills, and thetdstone2-troubleshooting6881 table. No score-hook / query rewriter is coded — the filter shape is the user's responsibility (guidance only). - Backward compatibility: default behaviour unchanged (no
MAP/STORAGEclause). New kwargs are keyword-only with defaults; existing positional calls andrun()'s read-onlyFilterManager(...)reconstruction are unaffected. Offline tests intests/test_manager_storage.py.
[0.3.1.143] - 2026-07-10¶
Added — 'uaf art view' process type (UAF features whose value lives in a secondary ART layer)¶
- New opt-in, additive
PROCESS_TYPE'uaf art view'for UAF (Unbounded Array Framework) features whose value lives in a secondary ART (Analytical Result Table) layer — ARIMA fit-metrics (R_SQUARE/MAPE/AICinARTFITMETADATA), residual tests overARTFITRESIDUALS,TD_PACF, etc. Reaching such a layer needs two statements (materialise the ART, thenTD_EXTRACT_RESULTSover aLAYER), which the single ingestion CTAS (CREATE VOLATILE TABLE AS (SELECT * FROM <view>)) cannot express — so the inline-EXECUTE FUNCTION-in-a-view trick that serves inline-output operators (TD_ACF,TD_DICKEY_FULLER) does not extend to it. upload_features(..., art_sql=[...], art_layers=[...])— new kwargs.art_sqlis a list ofEXECUTE FUNCTION ... INTO VOLATILE ART(<name>)statements;art_layersnames the declaredLAYER(...)s the feature view reads.df/sql_query+target_view_namestill build the plain feature view (reading the layer viaTD_EXTRACT_RESULTS(ART_SPEC(..., LAYER(...)))).- Storage: no DDL migration.
art_sql/art_layersare stored in the existingMETADATA JSON(32000)column of the process catalog (a JSON array preserves the execution order ofart_sql, which is load-bearing — a later statement may read an ART an earlier one created).PROCESS_TYPEis parameterised viakwargs.get('process_type', 'denormalized view')in the registration builders, so existing callers produce identical SQL. run(): a newelif process_type == 'uaf art view'branch readsart_sql/art_layersback fromMETADATAand threadsart_sqlthrough_upload_features→_compute_features_batch, which — per FilterManager partition, afterfiltermanager.update(), before the ingestion CTAS, in the same session — calls the new_materialise_art(art_sql): for each statement in list order,DROP TABLE <art>(idempotent re-run) then execute it. The session-scoped volatile ART is then visible to the CTAS reading the feature view. Partition-awareness comes from the store filter living in the series-input view's scalar subquery (same pattern asFilterManager/fs-filter).- AI-feature gates:
optimize_queryis force-disabled for this type (art_sqlis an opaque UAF invocation and the view EXPLAIN needs the ART materialised first);document_process_incrementalskips the view-EXPLAIN and feeds theart_sqloperator +art_layersinto the column-documentation LLM context;get_process_infoadditively surfacesART_SQL/ART_LAYERS. - Lineage traces through the ephemeral ART:
_extract_series_art_table_names(lineage/lineage.py) parsesTABLE_NAME(...)out ofSERIES_SPEC/ART_SPEC(inert on non-UAF SQL), andrecord_process_lineage(art_sql=, art_layers=)rewrites the danglingfeature_view → <ephemeral ART>edge into a two-hop pathfeature_view → <operator/layer node> → series_input_source(fail-open). - Backward-compatible:
art_sql=Nonetakes the classic'denormalized view'path byte-for-byte; the'denormalized view'/'tdstone2 view'branches are untouched. - Live-verified end-to-end on Vantage Cloud Lake (Vantage 20): ARIMA fit-metrics
R_SQUARE/MAPEfromARTFITMETADATA, 2-partition FilterManager — registration, METADATA, both-partition ingestion (values matching the ART ground truth), idempotentrun(force_compute=True), and lineage-through-ART all pass. See the notebook undernotebook dev/uaf art view/. Creation-time lifecycle constraint (verified): a view referencing a volatile ART cannot beREPLACE VIEW-created unless the ART already exists in the session (Error 3807) — materialise the ART once before creating the feature view;run()re-materialises it per partition thereafter.
Added — fs-tdstone2 skill¶
- New Claude Code skill
fs-tdstone2(atdfs4dsskill) documenting the one-call bridgetdfs4ds.upload_tdstone2_scores(model)— registering a trained tdstone2 model's per-partition predictions / computed features as governed features (entity derived from the model's mapper; snapshot vs recomputable-view trade-off; the ML-model lineage theca-algorithmskill walks backwards).
Tests¶
tests/test_uaf_art_view.py— DB-free unit tests for the'uaf art view'helpers (_extract_art_name,_extract_series_art_table_names,_uaf_art_rewrite).
[0.3.1.142] - 2026-07-10¶
Changed (Claude Code fs-* skills — sync verified gotchas + TDFS4DS_TD_* credentials)¶
- New shared reference
.claude/skills/fs-shared/gotchas.md: verified against tdfs4ds 0.3.1.141 and a live Vantage 20.0 instance. Covers connection, thesetup(database, if_exists='fail')signature (and whyif_exists='replace'is destructive), the "a data domain has no table of its own" create-vs-select semantics, the threeupload_featuresinput shapes,grouped=Truenot being persisted, the business-date-defaults-to-now behaviour, temporal-MERGE idempotency, Teradata SQL traps (ORDER BYinfrom_query,LIKE[...]character classes, CHAR-padding,BEGIN/END(VALIDTIME),copy_to_sqltype binding), lazytdml.DataFrame, and catalog object-name / return-type quirks. - All ten
fs-*skills: connection boilerplate now reads credentials fromTDFS4DS_TD_HOST/USERNAME/PASSWORD/DATABASE(the current convention used byreconnect/serve) instead of the obsoleteTDML_*vars; each skill now ends with a## Gotchassection linking to../fs-shared/gotchas.md. - fs-setup:
setup(database=...)is required (the old no-arg call wouldTypeError); neverif_exists='replace'; explicit create-vs-select data-domain rules with a fresh-store hedge pattern;LIKE ... ESCAPEcleanup SQL. The Vector Store / TeradataAI config block is retained. - fs-inspect: accurate catalog column comments (process catalog uses
ENTITY_ID, real dataset-catalog columns,FEATURE_VIEWvsVIEW_NAME) andselect_data_domain(...)instead of a directDATA_DOMAIN =assignment. - fs-upload: new "three input shapes of
upload_features" section (DataFrame / view-name /sql_query+target_view_name), the silent-None-on-failure warning, the always-schema-qualify rule, and the data-domain hedge in the code sample.
Documentation/skills only — no package code changed.
[0.3.1.141] - 2026-07-09¶
Added (Teradata Enterprise Vector Store — retry transient gateway 502/503/504 errors)¶
- Motivation: live VCL2 testing repeatedly hit genuine transient
502 Bad Gatewayresponses from the Vector Store REST service (and onecollection_status='create_failed'after an otherwise-clean-lookingfrom_datasets()call) — pure infrastructure flakiness unrelated to any code/config issue, that resolved on a simple retry a few seconds later. These were previously classified correctly as "service down" by_reraise_if_service_downbut required the caller to notice and manually retry. - Fix (agent/vector_store_teradata.py):
new
_is_transient_gateway_error()(detects a bare HTML 502/503/504 body, distinct from a genuine unreachable/unlicensed service) +_retry_if_transient_gateway_error()(retries for up to 60s, then falls through to the existing clear_SERVICE_DOWN_MSG). Wired into every REST-facing call site that showed this live:_lookup_store_info'sCollectionManager.list(),build_teradata_index'sfrom_datasets/add_datasetscalls,reset_teradata_store'sfrom_datasetscall, and_safe_destroy'sdestroy()call._reraise_if_service_downnow returns normally (leaving the caller's retry loop to handle it) for this third transient-exception class, alongside the existing "not in a ready state" 409 and per-query staging-object race.
Added (Teradata Enterprise Vector Store — collection description=)¶
build_teradata_index()/reset_teradata_store()now pass a fixed, human-readabledescription=per collection at creation time (_SOURCE_DESCRIPTIONSin agent/vector_store_teradata.py), visible viaCollectionManager().list()'scollection_descriptionfield — makes the three collections self-documenting when inspected outside tdfs4ds (e.g. via a shared VCL2 instance with many unrelated collections). Verified live.
[0.3.1.140] - 2026-07-09¶
Fixed (Teradata Enterprise Vector Store — _inputdata view permanently referencing a garbage-collected ephemeral view)¶
- Symptom: on a fresh VCL2 test deployment,
build_teradata_index()created all three collections without error andcollection_tdfs4ds_<source>_index(the embeddings table) held real, non-empty rows (13/16/73 for objects/sections/columns) — but everysimilarity_searchcall, and even a plainSELECT COUNT(*)againstcollectionV_tdfs4ds_<source>from a completely unrelated client (ateradata-mcp5MCP session, a freshteradatamlsession), failed identically withError 3807 / TDML_2412: Object 'data_scientist.ml___frmqry_v_<id>' does not exist— every time, not transiently (3/3 retries failed identically; the referenced object was confirmed absent fromDBC.TablesV). - Root cause, found by inspecting the actual DDL of the backing Teradata objects:
collection_tdfs4ds_<source>_inputdatais itself a view, and its storedSHOW VIEWDDL hardcodedFROM "data_scientist"."ml___frmqry_v_<id>"— a short-lived materialized-query view that Vantage garbage-collects almost immediately.collectionV_tdfs4ds_<source>(the join view search reads through) depends on_inputdata, so it inherited the same permanent breakage. Traced this to tdfs4ds's own call site (not just theteradatagenaiclient library, as the 0.3.1.131 fix assumed):_from_datasets_kwargs()built itsdata=argument viatdml.DataFrame.from_query("SELECT ... FROM <permanent view>")— and confirmed live thatDataFrame.from_query(...)itself sets_table_nameto a freshly materialized ephemeral view at construction time, beforeteradatagenaiis even called.teradatagenaithen bakes whatever_table_nameit receives permanently into_inputdata's DDL — so reading from a permanent source view (the 0.3.1.131 fix) was necessary but not sufficient; thefrom_query()wrapper itself was creating a doomed ephemeral view on every call. - Fix (agent/vector_store_teradata.py):
_from_datasets_kwargs()now passesdata=as a plain fully-qualified"schema.view"string naming the permanentFS_V_BUSINESS_DICTIONARY_*view directly — never afrom_query()result — guaranteeing no ephemeral materialization ever happens. Matchesteradatagenai's own documented simplest input shape (itsfrom_datasetsdocstring's own Example 1 passes a plain table name string). - No row-level filtering of
data=— by design, not an oversight: checked directly that neitherfrom_datasetsnoradd_datasetsexposes any filter/predicate/where-clause parameter. Considered and rejected pointingdata=at a filtered subset for scoped/incremental reindexing:_inputdatais a live view (re-evaluated on every query, not a frozen snapshot), so a filtereddata=would permanently narrow what the whole collection can ever return. Separately,_index's embeddings are keyed byTD_ID, which_inputdata(re-)assigns viaROW_NUMBER() OVER (ORDER BY <text column>)on every evaluation — a stable-looking key that is actually recomputed by row position, not a persistent identity. Always pass the entire, unfiltered source view. - Required recovery step for existing installs: collections created before
this fix have the broken ephemeral-referencing
_inputdata/collectionV_*DDL baked in permanently — an incrementaladd_datasetson top will NOT repair it. Calltdfs4ds.agent.vector_store_teradata.reset_teradata_store()(requires admin) once to destroy and recreate the collections using the fixed code path.
Fixed (Teradata Enterprise Vector Store — search_teradata_index silently returning 0 hits on genuine matches)¶
- Symptom: after fixing the
_inputdata/ephemeral-view bug above and confirmingsimilarity_search()genuinely returns real, relevant hits when called directly,tdfs4ds.search_vector_index()/search_teradata_index()— the actual public entry point — still returned 0 rows for the same query against the same collection, with no error or warning logged. - Root cause:
_parse_one()'s_TEXT_KEYS(("content", "page_content", "text", "document")) assumed a generic/Chroma-like hit shape. A real Teradata Vector Store hit is flattened differently: the text lives directly under the source's actual business-dictionary column name —business_description(objects/columns) orsection_content(sections) — not any of the generic keys, and metadata (object_name,section_name,column_name, etc.) is flattened into the same dict rather than nested under ametadatakey. Every hit'stexttherefore stayed empty, silently dropped by theif not text.strip(): return Noneguard — 5 genuine hits parsed down to 0 rows with no diagnostic trace. - Fix (agent/vector_store_teradata.py):
added
"business_description"and"section_content"to_TEXT_KEYS. Verified live: the same query that previously returned 0 rows throughsearch_vector_index()now returns 8 real, relevant hits acrossobjectsandsections, matching whatsimilarity_search()returns directly.
Known limitation (Teradata Enterprise Vector Store — build_teradata_index's reuse branch cannot pick up new rows via add_datasets; data_refresh=True investigated but found unreliable)¶
- Symptom: calling
build_teradata_index()a second time (collection already exists, e.g. after documenting one more table/column) fails withTDML_2412 ... Response Code: 400, Message: You can only add object names that do not exist in the collection— and this 400 is then misrouted through_reraise_if_service_down's generic"response code:"catch and re-raised as the unrelated_SERVICE_DOWN_MSG("service is unreachable or not enabled"), even though the service is clearly up (the same collection had just been created moments earlier). - Root cause:
add_datasets()'s real semantics are "attach a new source object not yet registered on the collection," not "resync/upsert rows from an already-attached source."_from_datasets_kwargs()always names the SAMEFS_V_BUSINESS_DICTIONARY_*view on every call, so any second call through the reuse branch is guaranteed to hit this 400 once the source is already attached — i.e. new business-dictionary rows are NOT currently picked up by a secondbuild_teradata_index()call. - Investigated and rejected as a fix:
Collection.update(data_refresh=True)— the documented mechanism ("refresh/regenerate vectors for the collection using existing backing/source tables," per its own docstring's Example 8) — looked like the correct replacement and succeeded once in isolated live testing (a new business-dictionary row correctly appeared in_index/_inputdata/collectionV_*, all in lockstep, 74/74/74). However it then failed consistently on every subsequent attempt — with a freshTeradataVectorStoreinstance, and even with a genuinely new, never-indexed row present in the source view — with"At least one of collection_index, collection_parameters, collection_description, or new_collection_name must be provided for update". This looks like an intermittent/stateful server-side issue interadatagenai, not something reproducibly fixable from the client side, so it was not adopted as the fix — shipping it would trade one known-broken path for a differently unreliable one.build_teradata_index's reuse branch remains onadd_datasets()(pre-existing behavior, known limitation). A real fix needs a dedicated follow-up oncedata_refresh's actual failure conditions are understood.
[0.3.1.139] - 2026-07-08¶
Fixed (Teradata Enterprise Vector Store — add_datasets() silently corrupting collections; "no exception" ≠ "write succeeded")¶
- Symptom: a live deployment showed
build_teradata_indexlogging"indexed 'tdfs4ds_objects' (reused existing collection)"(no exception raised) whileCollectionManager().list()showed the SAME collection'scollection_statusas'update_failed'. Searching for a document's own verbatim indexed text (search_vector_index(<verbatim text>, top_k=5)) returned zero hits at the same time "indexed successfully" was logged — proving the collections held no usable data despite the success log. A timeline pinned the corruption tobuild_teradata_indexitself: all three stores werecollection_status='ready'at 16:46, thenupdate_failed/create_failedat 16:49 — a ~3-minute window matching exactly onebuild_teradata_indexrun. - Leading root cause:
Collection.add_datasets()/update()accept anupdate_styleparameter ("MINOR"/"MAJOR") that tdfs4ds never passed. Checked theteradatagenaiclient source directly: every single documented usage example ofupdate()/add_datasets()/delete_documents()in the library's own docstrings explicitly passesupdate_style="MINOR"— none omit it, and no default is documented in the parameter metadata. This is a strong signal that omitting it is an unsupported code path server-side. Fixed:build_teradata_index'sadd_datasetscall now passesupdate_style="MINOR"(routine incremental upsert), and aMAJORfinalize pass (collection.update(update_style="MAJOR")) runs once per touched store after the per-store loop completes — matching the client library's intended MINOR-for-churn / MAJOR-for-periodic-consolidation split. - Secondary, defense-in-depth fix:
add_datasets()/from_datasets()are asynchronous — a call returning without an exception only means "the request was accepted," not "the write succeeded." Added_verify_write_succeeded()(agent/vector_store_teradata.py), which polls the realcollection_status(via the generalized_lookup_store_info(), extracted from the existing_lookup_store_database()— which previously discarded this field) until it reports'ready', called right after every write inbuild_teradata_index, before logging success. Raises clearly on timeout or ifcollection_statusreports a terminal*_failedstate instead. - New failed-state detection:
_is_failed_status()matches any..._failedcollection_status(confirmed live values:update_failed,create_failed). Bothbuild_teradata_index(pre-write) andsearch_teradata_index(pre-search) now check this first and raise/log a clear, actionable message pointing atreset_teradata_store()— instead of silently waiting/retrying into a collection that can never recover on its own, or (for search) silently skipping it behind an opaque"query failed (... not ready for asks ...), skipping"warning. - Usability fix:
search_vector_index()now acceptsk=as an alias fortop_k=(the near-universal convention in the surrounding ecosystem — Chroma, FAISS, LangChain retrievers all usek) instead of raising a rawTypeError: unexpected keyword argument 'k'.
[0.3.1.138] - 2026-07-08¶
Added (proactive readiness wait before every Vector Store write)¶
- Motivation: 0.3.1.137's
_retry_if_not_readyfixed the classification of the transient "not in a ready state" 409 (asyncCollectionwrites can still be busy when the next write targets them) but only retried by repeating the write itself, capped at 120s — not enough for a slower collection (tdfs4ds_columns, in a real deployment, kept 409ing well past that window). - Fix (agent/vector_store_teradata.py):
added
_wait_until_collection_ready(collection, name, timeout_seconds=1800.0, poll_interval=5.0), which polls the read-onlyCollection.get_details()every 5s until it succeeds — treating any exception (including the 409) as "still processing a prior async operation" — before the real write is attempted at all. Unlike retrying the write, a read-only probe can't itself add server-side contention. Raises the last observed exception if the collection is still not ready after 30 minutes (a genuinely stuck/failed operation surfaces clearly instead of hanging forever). - Wired into both write paths in
build_teradata_index(): before the initialfrom_datasets()create attempt (only when the collection is confirmed to already exist via_lookup_store_database— a genuinely nonexistent collection isn't "not ready", it's "not there", so the wait is skipped there), and before theadd_datasets()reuse-branch call. The existing_retry_if_not_readywrite-retry stays in place underneath as a belt-and-suspenders fallback.
[0.3.1.137] - 2026-07-08¶
Fixed (Teradata Enterprise Vector Store — transient "not in a ready state" 409 misclassified as service-down)¶
- Symptom:
build_teradata_index()(real deployment, after PAT auth started working per 0.3.1.136) failed indexing the third of three stores withTeradata Vector Store service is unreachable or not enabled ... Detail: [Teradata][teradataml](TDML_2412) Failed to execute collection. Response Code: 409, Message: Collection tdfs4ds_columns cannot be updated as it is not in a ready state.— even though the service was clearly reachable (the first two stores had just been indexed successfully in the same run). - Root cause: every
Collectionwrite operation (create/update/destroy) on the Enterprise Vector Store is asynchronous — the earlier logs show"Collection update request is accepted and in progress"for each store, meaning the collection can still be busy processing that request when the next store's operation targets it (or when a rapid create→reuse retry targets the same collection). Callingadd_datasets()while the collection is still busy raises a 409 whose message contains both "not in a ready state" AND "Response Code:" — the latter is exactly the substring_reraise_if_service_downused to detect a down/unlicensed service, so this transient, retryable condition was being raised as a permanentRuntimeErrortelling the user to check licensing. - Fix (agent/vector_store_teradata.py):
added
_is_not_ready_error(exc), checked first (before the generic"response code:"branch) inside_reraise_if_service_downso this specific 409 shape is never misclassified —_reraise_if_service_downnow returns normally for it, leaving it to the caller to retry. Added_retry_if_not_ready(fn, name, timeout_seconds=120.0, poll_interval=5.0), which retries a callable while it keeps raising this transient 409 (up to the timeout, then re-raises), and wrapped theadd_datasets()call inbuild_teradata_index()'s reuse path with it.
[0.3.1.136] - 2026-07-08¶
Added (tdfs4ds now manages teradataml.set_auth_token(...) automatically)¶
- Motivation:
VECTOR_STORE_BACKEND='teradata'authenticates against the Vantage Enterprise Vector Store REST API viateradataml.set_auth_token(...)— a separate auth channel from the regulartdml.create_context()DB connection. Previously tdfs4ds never called this; users had to call it themselves, and forgetting to do so produced the_NOT_CONFIGURED_MSGerror (added in 0.3.1.135). - New module
tdfs4ds.utils.vector_store_auth.configure_vector_store_auth()(utils/vector_store_auth.py): reads newTDFS4DS_VECTOR_STORE_AUTH_*pass-through environment variables (same tier asTDFS4DS_TD_*) and dispatches toteradataml.set_auth_token(...), trying three mechanisms in priority order (first fully-configured group wins): - PAT —
BASE_URL+PAT_TOKEN+PEM_FILE(+ optionalUSERNAME) - Basic —
BASE_URL+USERNAME+PASSWORD - OAuth —
BASE_URL+CLIENT_ID
Returns the mechanism name used (or None), and never raises — any
set_auth_token failure is caught and logged as a non-fatal warning. Never
logs secret values (pat_token, pem_file path, password, client_id),
only base_url/username.
- Wired in at every point a DB context is already established:
lifecycle.setup(), lifecycle.connect() (both log which mechanism was
used, or that none was configured), and serve/bootstrap.py's
_post_connect_setup() (called before the vector index build, so the build
succeeds once auth is configured).
- config.py: the six new vars added to _PASSTHROUGH_ENV_VARS, matching
exactly how TDFS4DS_TD_* is handled (read directly via os.environ, .env
support via the existing passthrough re-export mechanism).
- _NOT_CONFIGURED_MSG (agent/vector_store_teradata.py)
updated to describe the new automatic behavior and the three env var groups,
since reaching this error now means either no TDFS4DS_VECTOR_STORE_AUTH_*
vars were set, or set_auth_token itself failed (check the connect/setup
log for a "Vector Store auth ... failed" warning).
- JWT and Keycloak (two other set_auth_token mechanisms) are intentionally
out of scope — only PAT/Basic/OAuth are implemented.
[0.3.1.135] - 2026-07-08¶
Fixed (misleading "service unreachable" message when the Vector Store client isn't authenticated)¶
- Symptom:
build_teradata_index/build_vector_indexfailed withTeradata Vector Store service is unreachable or not enabled ... check that the Enterprise Vector Store feature is licensed/activated, withDetail: Invalid URL 'None/api/v2/collections?page_size=100000': No scheme supplied— even though the service itself was fine. - Root cause:
_reraise_if_service_downtreated everyrequests.exceptions.RequestExceptionas "service down/unlicensed." ButInvalid URL 'None/...'isrequests.exceptions.MissingSchema/InvalidURL— a client-side failure: the Vector Store REST client's base URL isNonebecauseteradataml.set_auth_token(...)(or equivalent session auth) was never called before using the backend. tdfs4ds does not callset_auth_tokenon the user's behalf (it's a connection/auth concern outside its scope), so this is a setup-order gap, not a licensing problem — but the error message pointed the user at the wrong fix. - Fix:
_reraise_if_service_down(agent/vector_store_teradata.py) now checks forMissingSchema/InvalidURL(and the equivalent wrappedTeradataMlExceptionmessage shape, "no scheme supplied"/"invalid url") before the genericRequestExceptioncheck, raising a new, distinct_NOT_CONFIGURED_MSGRuntimeError that tells the user to callset_auth_token(...)— instead of the misleading "unreachable/unlicensed" message.
[0.3.1.134] - 2026-07-08¶
Fixed (reset_teradata_store — wait for async destroy() before recreating)¶
- Symptom: even with an explicit
destroy()that reported success ("Collection destroy request is accepted and in progress"), a followingfrom_datasets()call could still raiseTeradataGenAIException: Collection with name '...' already exists.— fromCollection.create()'s ownself.existscheck. - Root cause:
Collection.destroy()is asynchronous — it returns as soon as the request is accepted, not once the collection is actually gone server-side.reset_teradata_store()(added in 0.3.1.133) called_safe_destroy()then immediatelyfrom_datasets(), racing the still-in- progress destroy. - Fix: added
_wait_until_destroyed(name, timeout_seconds=60.0)(agent/vector_store_teradata.py), which polls_lookup_store_database()until the collection is confirmed gone (or the timeout elapses, logged as a warning rather than raised — the subsequentfrom_datasets()call will surface a clear error itself if the collection is genuinely still there).reset_teradata_store()now calls this between_safe_destroy()and the recreate.
[0.3.1.133] - 2026-07-08¶
Fixed (Teradata Enterprise Vector Store — clear gateway errors + drop the admin-only destroy from routine rebuilds)¶
- Symptom 1:
tdfs4ds.rebuild_vector_indexing(force_rebuild=True)(and anybuild_vector_index/build_teradata_indexcall) could crash with a rawteradataml.common.exceptions.TeradataMlException(TDML_2412) wrapping an HTML502 Bad Gatewaybody, several frames deep insidelangchain_teradata/teradatagenaiinternals (Collection.exists()→CollectionManager().list()), instead of the clear "Vector Store service is unreachable or not enabled" message tdfs4ds already raises elsewhere. - Symptom 2 (surfaced once the gateway/auth issue above was resolved):
the same call then failed with
TeradataGenAIException: Collection with name 'tdfs4ds_objects' already exists.— raised byCollection.create()'s own authoritativeself.existscheck, immediately afterbuild_teradata_index()had just attempted to destroy that same collection. - Root cause (symptom 1): the clear-message classification
(
_raise_service_unavailable, detecting a transport-level failure or an HTML/status body embedded in aTeradataMlException) previously lived only inside_lookup_store_database(). The actualTeradataVectorStore.from_datasets()/add_datasets()calls inbuild_teradata_index()had no exception handling at all, so a gateway error from those calls (which internally re-enter the sameCollection.exists()check) bypassed the classification and propagated raw. - Root cause (symptom 2):
build_teradata_index(force_rebuild=True)called_safe_destroy()before every rebuild, which only invokeddestroy()whenCollection.status()reported a non-empty operation history — butstatus()reports async create/destroy/update operations, not current existence, so an existing collection with no reported history was wrongly concluded "nothing to destroy" and left in place. Separately,Collection.destroy()requires admin privileges ("Only admin users can use this method" per its own docstring); any destroy failure (e.g. a non-admin session) was caught and only logged as a warning, silently leaving the stale collection for the followingfrom_datasets()call to collide with. - Fix:
- Extracted the service-down classification into a shared
_reraise_if_service_down(exc)helper (agent/vector_store_teradata.py), reused by_lookup_store_database()and wrapped around everyfrom_datasets()/add_datasets()call site. build_teradata_index()no longer destroys anything. It always attaches-or-creates viafrom_datasets()/add_datasets()(which creates the collection on first use and upserts/appends rows keyed onIDafterward — no per-id duplication), sidestepping the admin requirement entirely.force_rebuildis now purely informational for logging; both branches behave identically. This means rows whose source business-dictionary entry was deleted are not purged by this call (no admin-free per-id deletion API is available yet —Collection.get_ids()is currently an unimplemented stub upstream).- Added
reset_teradata_store(sources=None)— an explicit, admin-only destroy-and-recreate for when a true clean wipe (stale-row purge) is needed and the connected user has admin rights. The routinebuild_vector_index()/rebuild_vector_indexing()path deliberately does not call it, so non-admin sessions keep working. rebuild_vector_indexing()'s andbuild_vector_index()'s docstrings updated to describe the Teradata backend's reuse-only behavior (the Chroma backend'sforce_rebuild=Truestill deletes and rebuilds from scratch, unchanged).
[0.3.1.132] - 2026-07-08¶
Fixed (business-dictionary views not (re)created on installs from an older tdfs4ds version)¶
- Root cause: the three
FS_V_BUSINESS_DICTIONARY_OBJECTS/_SECTIONS/_COLUMNSviews (introduced in 0.3.1.131 for the Teradata Enterprise Vector Store) were only created insidedocumentation_tables_creation(), whichconnect(create_if_missing=True)only calls when the underlying business-dictionary tables are detected as missing. On any install where those tables were created by an older tdfs4ds version,connect()saw the tables already present, skipped the creation branch entirely, and the views were never materialized — causingError 3807: FS_V_BUSINESS_DICTIONARY_OBJECTS does not existthe first time the Teradata vector store backend queried them. - Fix: extracted the view-creation loop out of
documentation_tables_creation()into its owndocumentation_business_dictionary_view_creation()(genai/documentation.py, re-exported fromtdfs4ds.genai).connect()now calls it unconditionally on every connect, right alongside the existing unconditionalfeature_store_catalog_view_creation()/process_store_catalog_view_creation()refresh in the "Refreshing catalog views..." step (lifecycle.py) — the same self-healing pattern those two already used, rather than depending on a table-existence probe to infer whether the views need (re)creating.REPLACE VIEWis idempotent, so this adds no meaningful cost and guarantees the views exist after anyconnect(), regardless of what version created the underlying tables.setup()was already covered (it always callsdocumentation_tables_creation(), which now includes the view creation via this refactor).
[0.3.1.131] - 2026-07-07¶
Fixed (Teradata Enterprise Vector Store — real fix for staging-database bug)¶
- Root cause found:
target_database(0.3.1.129/130) only tells the Vantage REST API where to register the FINAL collection's metadata — it does NOT control wherelangchain_teradata'sfrom_documents()/add_documents()(LangChainDocument-list input) stage their intermediate data. That path internally callsteradataml.DataFrame.from_dict(..., persist=True)/DataFrame.materialize(), which takes no database argument at all and — perteradataml's own docstring — may materialize as a view or a volatile table, "decided and taken care by teradataml" internally. A volatile table is session-scoped and invisible to the Vector Store's separate REST session, reproducingTDML_2412: Object ... does not existregardless oftarget_database. - Fix:
build_teradata_index()now callsTeradataVectorStore.from_datasets()/add_datasets()instead offrom_documents()/add_documents(), reading directly from three new permanentCURRENT VALIDTIMEviews —FS_V_BUSINESS_DICTIONARY_OBJECTS/_SECTIONS/_COLUMNS(documentation_tables_creation(),tdfs4ds.DOCUMENTATION_BUSINESS_DICTIONARY_*_VIEW) — via plain SQL +tdml.DataFrame.from_query(). NoDocumentconversion, no pandas round-trip, no ephemeral/volatile/temporary table of any kind — the vector store reads the same permanent business-dictionary table every other part of tdfs4ds treats as the source of truth. The views bake inLOCKING ROW FOR ACCESS, a synthesizedIDcolumn (matching the existing Chroma-backend doc-id convention), and a blank/NULL-description filter (mirroring_objects_to_docs/etc.'sif not text.strip(): continueguard).
[0.3.1.130] - 2026-07-07¶
Fixed (Teradata Enterprise Vector Store — orphan prevention + service-down handling)¶
target_databaseon everyTeradataVectorStorecall: the incremental-attach path inbuild_teradata_index()andsearch_teradata_index()now passtarget_database(scoped totdfs4ds.SCHEMA) alongside the creation path fixed in 0.3.1.129, so attach/search are explicit about which database they resolve against instead of relying on the Vantage session's ambient current database.- Pre-destroy visibility via
CollectionManager: new_lookup_store_database(name)helper (tdfs4ds/agent/vector_store_teradata.py) callsteradatagenai'sCollectionManager().list()to discover which database a same-named store actually lives in before attaching._safe_destroynow destroys the store at its discovered database (not blindly attdfs4ds.SCHEMA) and logs a warning when it's found somewhere unexpected — closing an orphan risk where a stale store from before thetarget_databasefix would never be reached/destroyed onforce_rebuild, leaving a duplicate behind.build_teradata_indexalso logs the intended target database once per run. - Service-down handling:
_lookup_store_databasedistinguishes a genuinely unreachable or unlicensed Vector Store service (transport-levelrequestserrors, or a gateway 404/502 HTML body embedded in aTeradataMlException) from real in-service errors, and raises one clearRuntimeError("...unreachable or not enabled... check licensing/activation...") instead of a rawrequeststraceback or an HTML-in-exception message. This propagates correctly throughbuild_teradata_index's incremental-attach path (previously it would have been misclassified as "store doesn't exist, rebuilding").
[0.3.1.129] - 2026-07-06¶
Fixed (Teradata Enterprise Vector Store)¶
target_databaseon vector store creation:TeradataVectorStore.from_documents(...)inbuild_teradata_index()now passestarget_database=tdfs4ds.SCHEMAexplicitly. Without it, the Enterprise Vector Store staged its intermediate object in the Vantage session's ambient "current database" (the login user's default DB), nottdfs4ds.SCHEMA/ theteradatamlcontext'sdatabase=/temp_database_name=— causingTDML_2412: Object ... does not existwhen those differ.
[0.3.1.116] - 2026-06-22¶
Fixed (eval + dashboard + agent + API)¶
- Eval progress bar: shows elapsed time and estimated remaining time during SSE run (
⏳ 2/44 — 0:12 elapsed, ~0:45 remaining; done shows total duration) - Eval report width:
#ev-reportnow haswidth: 100%so the report fills the full panel - Judge — EXPLAIN grounding:
_verified_names_from_contextnow extractssource_columnstable/column names andcalculation_sqlformula tokens from the trace, so EXPLAIN-derived facts (e.g.CLAIMS_RAW,quantity) are verified rather than treated as fabrications; S05-style all-1 misscores fixed - Judge — EXPLAIN prompt rule: added conditional "EXPLAIN GROUNDING RULE" clause to
_judge_turnprompt for EXPLAIN turns - Judge — DEFINITION completeness: added "DEFINITION COMPLETENESS RULE" clause so judge does not penalise for omitting navigation metadata (
domain,entity_name) from the answer body - Visualization — radar chart: legend moved below chart with wider bottom margin (
height=520,margin b=140, horizontaly=-0.22); labels no longer truncated to 1 character - Visualization — bar chart: increased height and bottom margin (
height=420,margin b=100,y=-0.28); metric labels no longer truncated - Dashboard run history: x-axis now shows view names (
vw_aggregate_features) instead of process UUIDs;_dash_run_history_figsacceptspid_to_viewmapping from caller - Query optimizer API: new
POST /v1/optimizeendpoint — accepts{"sql": "...", "user_request": "..."}, returns{optimized_sql, optimization_strategy, token_consumption};/v1/chat/completionsfortdfs4ds-query-optimizermodel now includesoptimizedandscore_deltain theusageblock - Domain typo:
node_detect_domainnow fuzzy-matchesobject_nameagainst known data domains whendetect_data_domain_for_objectreturns nothing (e.g. "anomalu" → "anomaly");_build_context_updatenormalises a typo domain against the existing graph's known domains as a last-resort guard, preventing duplicate domain nodes in the knowledge graph visualization
[0.3.1.115] - 2026-06-22¶
Fixed (admin web shell — 13 UI rendering issues)¶
Multi-fix release addressing visual and functional bugs in the Tera admin shell:
Chatbot: removed the "Loading…" spinner flash when the Chatbot nav item is first selected (the iframe replaces content immediately so no spinner is needed).
Query Optimization:
- "Current SQL" left column now shows the actual view SQL (PROCESS_SQL key, not the
missing SQL key).
- "Process detail" card renders styled HTML directly (badges, warnings, SQL block)
instead of passing HTML-injected markdown through marked() which escaped the tags.
- "Process context" footer (_Process context — Entity: … | Features: …) is now a
styled light-grey info bar instead of raw italic text.
- Score Summary and other ## sections with GFM pipe-tables are now converted to
real <table> HTML before rendering.
Evaluation:
- "Generate scenarios" button now shows an animated orange progress bar while the
request is in flight and re-enables on completion.
- By-skill score table replaced with a grouped bar chart (0–5 scale, one bar per
metric per skill) — more readable at a glance.
- Radar chart legend moved to the right side (orientation="v", x=1.02) so it
no longer overlaps the polar area.
- Scenario list table header <th> cells now carry color:#fff; background:#00233C
inline — previously only the <tr> had these styles, which some CSS resets
overrode.
- Agent answers and judge rationale in the HTML report are now rendered as HTML
(bold, inline code, bullet lists) instead of raw escaped text.
Dashboard:
- "Run history" charts: follow_up_report_many().to_pandas() moves PROCESS_ID
to the pandas index (it is the PRIMARY INDEX); added .reset_index() so the
column is available for grouping — previously all queries returned empty.
- "Compute row counts" button now shows an animated progress bar while the
(potentially long-running) per-table COUNT queries are executing.
Layout:
- All panels no longer shrink horizontally when content loads; #td-main now has
width: calc(100% - 280px); box-sizing: border-box and .td-panel has
width: 100% — panels were previously relying on default width: auto
(shrink-to-fit), which collapsed when wide content was injected.
- Added @keyframes td-slide animation to td.css for the progress bars.
[0.3.1.114] - 2026-06-21¶
Fixed (context knowledge graph — orphan feature nodes on a multi-entity SUMMARY)¶
Follow-up to 0.3.1.113. Asking "what are the available features in the claims anomaly
detection data domain?" still rendered some features (e.g. pharmacy_lifetime_avg_amount)
as isolated nodes — no edge to their entity or data domain — even though the catalog
has both. Invariant: a feature must ALWAYS link to an entity AND a data domain.
Root cause: _build_context_graph_pyvis (agent/graph.py)
renders features in three places. A feature_list pre-seed loop minted each feature's node
id from _focus_entity (EMPTY for a multi-entity SUMMARY — node_skill_summary only sets
it when there's exactly one entity) and no domain, producing a blank-identity node
feature___NODOM____NOENT__<name> with zero edges. The real render loops mint the SAME
feature with its true (entity, domain) triplet → a different node id, correctly linked.
The node-merge net never merges two Feature nodes with different ids (triplet identity is
intentional, so same-name/different-entity features stay distinct), so the blank-id pre-seed
node survived as the floating orphan.
Fix: the pre-seed loop now recovers each feature's REAL (entity, domain) from the
accumulated graph (features keyed by the DOMAIN::ENTITY::NAME triplet) and mints one
node per entity variant, each linked to its entity (and the entity to its domain) — so
the node ids collide with the real loops (one node, properly linked) and all
same-name/different-entity/domain variants are preserved. A feature with no accumulated
entry yet (pure group-pronoun-freshness) is anchored to the domain node so it is never
isolated. Shared edge logic factored into a _link_feature_node helper.
Defense in depth: skill_summary's cg_features
(agent/consumer_agent.py) now stamps each feature's
domain (active tdfs4ds.DATA_DOMAIN), and node_skill_summary passes the resolved
summary domain (not the possibly-stale resolved_data_domain, and never the unscoped
"all") to _build_context_update — so features are never domain-less in the accumulated
graph.
[0.3.1.113] - 2026-06-21¶
Fixed (context knowledge graph — entity-aware feature↔dataset linking)¶
Two bugs in the chatbot "Context knowledge graph" (the pyvis viz built from
accumulated_context_graph), both visible after a SUMMARY question
("what are the available features in a data domain?"):
- Orphan single features. A feature like
unique_drugsrendered as an isolated node — no edge to its entity, dataset, or the data-domain node. - Dataset linked to the wrong same-named feature.
ds_pharmacy_stats(grainpharmacy_id) drew dataset→feature edges to BOTH thepharmacy-entity AND theprovider-entity features of the same name (total_amount,avg_amount, …).
Root cause (single): _feature_dataset_names_map()
(agent/consumer_agent.py) keyed its feature→dataset map
on FEATURE_NAME alone, dropping the entity dimension. A feature's identity is the pair
(FEATURE_NAME, ENTITY_NAME), so a name-only key conflated two distinct features and gave
each the union of every dataset exposing that name.
Fix: the dataset _FEATURES table carries FEATURE_ID (the feature's identity); the
feature catalog (FS_FEATURE_CATALOG, keyed by FEATURE_ID BIGINT) resolves it to the
authoritative ENTITY_NAME/DATA_DOMAIN. _feature_dataset_names_map() now joins the
two on FEATURE_ID (in pandas over single materializations — no per-row DB query) and
returns two maps: by_entity keyed on (ENTITY_UPPER, FEATURE_NAME_UPPER) (primary) and a
by_name fallback for features whose FEATURE_ID has no catalog match (entity-less /
cross-domain). skill_summary looks up the (entity, name) key first, falling back to
name-only only when the feature has no entity — so a dataset links only to the entity it
actually exposes, and entity-less features stay connected. Per-turn memoization and
fail-open behavior unchanged.
[0.3.1.112] - 2026-06-21¶
Fixed (reverse-proxy path prefix — Gradio chatbot gradio_api URLs 404'd behind /tdfs4ds2)¶
Follow-up to 0.3.1.111. The FastAPI shell + /ui/* routes resolved correctly under the prefix,
but the Gradio chatbot did not: its page emitted browser-facing asset/API URLs as
root + api_prefix (e.g. /gradio_api/queue/join) with no prefix, because that root comes
from the mount's root_path, not from the ASGI scope the _strip_prefix middleware rewrites. So
the browser called https://…/gradio_api/... (un-prefixed) → nginx had no matching location →
404. (0.3.1.111 mounted Gradio plainly at /chatbot with no root_path, which is what caused this.)
Fix, both in serve/app.py:
- Mount Gradio at the full prefixed path with a matching
root_path:gr.mount_gradio_app(app, blocks, path=root_path+"/chatbot", root_path=root_path+"/chatbot"). In Gradio 6.x,root_pathgoverns both the browser-facingrootURL and the inner ASGI app's own route matching — they must be equal or every Gradio route 404s (mounting plainly OR passing onlyroot_pathboth break). With them equal, Gradio emits the prefixedroot(…/tdfs4ds2/chatbot) so the browser calls…/tdfs4ds2/chatbot/gradio_api/.... - The
_strip_prefixmiddleware now SKIPS the Gradio mount path ({prefix}/chatbot) — Gradio owns the full prefixed path and matches it directly; only the prefix-less shell//ui/*//v1/*routes are stripped. The mount path is stashed onapp.state.td_chatbot_mount.
The iframe src="chatbot" is relative, so it resolves under <base href="/tdfs4ds2/"> to the
prefixed mount automatically. No nginx changes needed.
Verified via TestClient in all three modes (prefix forwarded unstripped / stripped / root):
shell, /ui/*, static, and /chatbot/config (with root = …/tdfs4ds2/chatbot) all resolve.
[0.3.1.111] - 2026-06-21¶
Fixed (reverse-proxy path prefix — landing page 404'd behind /tdfs4ds2)¶
The admin UI returned {"detail":"Not Found"} when served behind a reverse proxy under a
path prefix (e.g. https://api-dmproject.myddns.me/tdfs4ds2). Two causes, both fixed in
serve/app.py:
- The app passed
root_pathto the FastAPI constructor, which (a) does not make routes match a forwarded-but-unstripped prefix and (b) broke the/ui/staticStaticFiles Mount and the Gradio/chatbotmount. Removed the constructorroot_path. - The prefix is now handled by a per-request
_strip_prefixASGI middleware: when the proxy forwards/tdfs4ds2/...unstripped, it strips the prefix so the (prefix-less) routes + mounts match, and stashes the external prefix onrequest.stateso the shell injects the correct<base href="/tdfs4ds2/">. Works whether or not the proxy strips the prefix, and at root. - Gradio is mounted plainly at
/chatbot(noroot_path=kwarg) — the middleware already handles the external prefix; passing both made Gradio's internal routes 404.
Verified: shell, CSS/JS, /ui/*, /health and /chatbot all resolve under /tdfs4ds2/...,
under a stripped prefix, and at the root.
[0.3.1.110] - 2026-06-21¶
Fixed / Added (triplet identity, judge, bidirectional lineage, admin UX, resilient startup)¶
- Context graph respects the feature TRIPLET
(DATA_DOMAIN, ENTITY_NAME, FEATURE_NAME)(agent/graph.py)._build_context_graph_pyviskeys feature nodes by the full triplet (_feat_node_id) and entity nodes by(domain, entity), so the same feature name under two entities (e.g.amount[provider_id]vsamount[pharmacy_id]) renders as distinct nodes (same label) instead of collapsing into one. The post-pass dedup no longer merges Feature/Entity nodes by label._feat_cache_keynow includes the active domain (DOMAIN::ENTITY::NAME) — since the domain can change mid-conversation, a cached feature from domain A is never served for domain B;_feat_cache_lookupstays back-compatible with legacy keys. - Within-domain entity disambiguation in name-only skills (agent/consumer_agent.py):
skill_usageandskill_datasetconstrain by the hinted/named entity (or return theambiguous_featureclarification) instead of silently taking the first row; multi-feature freshness fans out per(entity, process)so a name under two entities reports both. - LLM-as-judge no longer scores correct counts as fabrication (agent/evaluation.py).
_expected_factsfor DATA_DOMAINS / SUMMARY now carries the realfeature_countandfeature_count_by_entity(triplet-deduped); the judge registers those numbers as fact-hits and the prompt states catalog-derived counts are real. Fixes the S17 class of straight-1s scoring. - Bidirectional, object-level lineage over the persisted graph:
skill_lineage's primary path now positions the resolved view inFS_LINEAGE_GRAPHvia_position_object_from_graph(bothget_ancestors+get_descendants), so "what depends on X?" (downstream) works, not just upstream. A_lineage_direction(question)signal (EN/FR) selects upstream/downstream/both. - Admin UI: panels are now persistent (each rendered once into its own container, toggled
by display) so switching panels no longer destroys content — the chatbot iframe is never reloaded,
so conversation survives. The Evaluation panel regained skills selection, scenario table view,
export, import (replace/append) and gained live per-turn SSE progress plus a radar chart +
color-coded per-skill table (serve/static/app.js,
serve/panels.py, serve/app.py,
_ev_radar_chart_standalone/_ev_score_table_standalonein agent/chatbot.py). - Resilient startup + in-app Connect (serve/bootstrap.py):
bootstrap()no longer raises when Teradata is unreachable — the app boots disconnected,/healthreportsconnected, and a Connect page (top-bar Account icon) lets the user enter credentials and connect at runtime (POST /ui/connect→connect_teradata). Data panels degrade gracefully when disconnected; a status dot reflects the live state. - New packaged skill
eval-inspect(tdfs4ds/skills/eval-inspect/): read-only stdlib query tool for an eval results JSON — by scenario id, worst N turns, intent mismatches, per-skill aggregates (with judge rationale).
[0.3.1.108] - 2026-06-20¶
Changed (admin app — Gradio only for the chatbot; custom Teradata-styled web shell for everything else)¶
The Gradio tabbed admin app (launch_chatbot_app) is replaced by a server-rendered
HTML/JS admin web shell served by the FastAPI app, reproducing the Teradata "Tera"
console look. Gradio now renders only the conversational chatbot, embedded in an
iframe at /chatbot. The whole UI runs on TDFS4DS_SERVE_PORT. The app supports a
reverse-proxy path prefix (TDFS4DS_SERVE_ROOT_PATH, e.g. /tdfs4ds2), and for
backward compatibility the standalone Gradio chatbot is also still served on its own
port (TDFS4DS_SERVE_CHATBOT, default on; TDFS4DS_SERVE_CHATBOT_PORT, default 7860).
tdfs4ds/serve/app.py—_add_admin_ui(app)mounts the chat-only Gradio Blocks at/chatbot(gr.mount_gradio_app), serves the shell at/, static assets at/ui/static, and/ui/*JSON/HTML endpoints for the Dashboard, Lineage, Query optimization, Evaluation, Parameters and Skills panels.tdfs4ds/serve/panels.py(new) — Gradio-free panel logic returning JSON/HTML; delegates to the existing module-level builders inagent/chatbot.py.tdfs4ds/serve/templates/index.html,serve/static/td.css,serve/static/app.js(new) — the Teradata-styled shell (top bar +teradata.wordmark, ~280px left rail with lavender active pill + BETA badges, per-panel content area).agent/chatbot.py—build_chat_blocks()builds the embeddable chat-only Blocks (Tera hero + rounded-pill input + suggestion chips);_build_chat_ui(with_skills_pane=False)omits the skills column (skills + Parameters move to the shell).launch_chatbot/launch_chatbot_with_indexare unchanged.- Path-prefix support — set
TDFS4DS_SERVE_ROOT_PATH(e.g./tdfs4ds2);create_apppasses it to FastAPIroot_path+ Gradio's mount, and the shell injects a<base href>so every asset/fetch/iframe URL (relative on the client) resolves under the prefix.uvicornruns withproxy_headers=True. Example target:https://api-dmproject.myddns.me/tdfs4ds2. - Deployment —
Dockerfile,docker-compose.yml,.env.examplekeep ports 8000 (UI + API), 7860 (standalone chatbot) and 8080 (docs). Run withpython -m tdfs4ds.serve.
[0.3.1.99] - 2026-06-17¶
Fixed (consumer agent — compound EXPLAIN+ALGORITHM question routed to EXPLAIN only when MCP on; invented STO SQL syntax)¶
Two issues with the ALGORITHM skill when MCP is enabled:
1. Compound question routed to EXPLAIN only. A question like "How is ml_risk_score computed? What algorithm is behind it?" has both EXPLAIN signals ("how is … computed") and ALGORITHM signals ("algorithm"). With MCP enabled, the classifier anchored on the EXPLAIN shape and routed to the EXPLAIN node — the user got the SQL formula explanation but not the ML model details.
- Routing guard (
_node_classify_implin agent/graph.py): when MCP is on and the classifier returnsEXPLAIN, re-route toALGORITHMif_looks_like_algorithm_questionalso matches. Symmetric with the MCP-off guard that routes toALGORITHM_UNAVAILABLE. - Dual-intent execution (
node_skill_algorithm): detects both signals (_EXPLAIN_SHAPE_RE _looks_like_algorithm_question) and runsskill_explainfirst when both are present, prepending its answer above the ALGORITHM answer with a---divider. Column sources from the EXPLAIN pass are persisted toresolved_column_sourcesso EXPLAIN→DEFINITION drill-down works on the next turn.
2. Fabricated CALL sto.sto(...) SQL syntax.
When asked about the STO invocation or how to build a similar model, the LLM reconstructed a
CALL sto.sto(PARTITION BY ..., SCRIPT_COMMAND '...', RETURNS (...)) — syntax that does not
exist in Teradata. The real invocation is FROM Script(ON ... PARTITION BY ... ORDER BY ...
SCRIPT_COMMAND(...) RETURNS(...) CHARSET('LATIN')) AS d inside a REPLACE VIEW.
Root cause: _algorithm_system_prompt included parsed STO fields (partition_by, order_by,
script_command, returns, charset) as JSON — the LLM had reconstruction material and
invented a plausible-looking but wrong call syntax.
_raw_ddlanti-fabrication guard (agent/consumer_agent.py):skill_algorithmstores the raw training STO view DDL assto_invocation["_raw_ddl"]._algorithm_system_promptpops_raw_ddlbefore JSON serialisation (removing the parsed fields as reconstruction material) and injects the raw DDL as a clearly-labelled authoritative block with explicit instruction "quote theFROM Script(...)block verbatim — NEVER writeCALLsyntax or fabricate SQL".
Test: tests/test_algorithm_params.py::test_algorithm_system_prompt_raw_ddl_injected_and_stripped_from_json.
[0.3.1.98] - 2026-06-16¶
Fixed (feature ingestion — CHAR entity-column length truncated; NULL in PI keys causes cartesian MERGE)¶
Two data-correctness bugs in upload_features when entity_id is passed as a list or string
(type inference path):
1. CHAR entity-column length silently truncated.
A source view with CLI_CD CHAR(14) produced an FS_T_* feature table with CLI_CD CHAR(1).
Root cause: get_column_types (utils/info.py) applied the
length-preserving path only for VARCHAR, not CHAR. The raw _td_column_names_and_types value
for a CHAR column drops the length, and the FS_T_* DDL was built verbatim from that dict.
Fix: get_column_types now delegates all character columns (CHAR and VARCHAR) to
get_feature_types_sql_format — the existing exact-type extractor used for feature columns.
It is type-agnostic and length+charset-preserving (reads CAST(col AS <type>) from
show_query()). Fail-open: if the exact extraction raises, falls back to the previous
behaviour for the affected columns.
2. NULL in PI key columns causes cartesian-product MERGE / skew.
NULLs in entity/primary-index columns were only handled when entity_null_substitute was
passed. Without it, the MERGE ON clause used plain equality so un-substituted NULL keys
never matched, causing rows to fan out (cartesian-like) with massive runtime and storage cost.
Fix: a NULL-count check runs immediately after the existing duplicate check in
prepare_feature_ingestion (feature_store/feature_data_processing.py).
If any entity column still has NULLs in the volatile table (i.e. after any COALESCE from
entity_null_substitute), raises ValueError naming the column(s). Mirrors the duplicate-row
guard exactly: same location, same log+raise pattern, same DEBUG_MODE sample query.
Tests: tests/test_entity_type_inference.py, tests/test_null_entity_guard.py.
[0.3.1.97] - 2026-06-16¶
Fixed (consumer agent — OUT_OF_SCOPE regression on compound EXPLAIN/ALGORITHM questions)¶
v0.3.1.96's tightened ca-explain description (an adversarial "SKIP when the user asks which
ML ALGORITHM …" clause) made the small classifier (ministral-14b) bail to OUT_OF_SCOPE on
a compound, in-scope question under multi-turn history — e.g. "How is ml_risk_score computed?
What algorithm is behind it?" (MCP on) wrongly refused, where v0.3.1.94 answered it as EXPLAIN.
The conflicting EXPLAIN + ALGORITHM cues plus 9 turns of context tipped the model over.
- Description softened (skills/ca-explain/SKILL.md):
dropped the adversarial ALGORITHM-SKIP line; kept the "how/formula is ALWAYS EXPLAIN, even
for ML-backed features and even when the sentence also mentions the algorithm" anchor. The
ca-algorithmclassifier example already disambiguates the algorithm-only case. - Deterministic safety net (
_rescue_out_of_scopein agent/graph.py, wired into_node_classify_impl): when the classifier returns OUT_OF_SCOPE but the question names a KNOWN catalog feature/object (_known_catalog_names, word-boundary match), re-route — EXPLAIN for a how/formula shape (EN/FR), DEFINITION otherwise. Grounded in the catalog so a genuinely off-topic question (e.g. "what's the weather?") still refuses, and the algorithm-only question still routes to ALGORITHM (MCP on) / the "enable MCP" message (MCP off).
Verified live (demo, MCP on/off): the compound question answers (EXPLAIN), algorithm-only →
ALGORITHM, off-topic → refusal. Test: tests/test_out_of_scope_rescue.py.
[0.3.1.96] - 2026-06-16¶
Fixed (consumer agent — EXPLAIN "Variables involved" accuracy)¶
For rule_based_score, EXPLAIN's "Variables involved" listed 13 columns when the formula
references only 5, and rendered subquery aliases (flags, base_data) as if they were tables.
- Over-listing → transitive-closure pruning. The LLM populates
source_columnsfrom the whole DDL;_validate_source_columns(agent/consumer_agent.py) only appended, never pruned. New_formula_column_closure(calculation_sql, ddl)keeps the formula's direct variables plus the grain keys reached through their derivation (windowPARTITION BY/ORDER BYandGROUP BYkeys — e.g.patient_id, drug_code, claim_datebehind aROW_NUMBER()-based duplicate flag), and drops the plain arithmetic operands of an already-listed derived feature (provider_avg,amount, …). Wired intoskill_explainvia_prune_source_columns. Fail-open: no pruning when formula/DDL absent. - Alias-as-table → resolve or drop.
_resolve_source_table_alias+_extract_ddl_table_aliasesresolve an LLM-claimedsource_tableto the real base table (FROM claims_raw c→c⇒CLAIMS_RAW), or returnNonefor a subquery/wrapper alias (flags,base_data,ps). Applied inside_validate_source_columnsso a non-table alias never survives intoresult["source_columns"]— the renderer and the accumulated context graph both inherit clean data.graph.py_build_context_updateadditionally normalizes LINEAGEsource_tablesvia_normalize_source_tables(drop names not in the view's FROM/JOIN set). Fail-open on missing DDL.
Added (consumer agent — ALGORITHM: input grounding + reproduction-grade parameters)¶
The ALGORITHM skill can now describe everything needed to rebuild a similar tdstone2 process, and grounds the algorithm in what its inputs mean:
- Input grounding (D):
skill_algorithmresolves the business descriptions of the model'scolumn_names_Xinputs (reusing_explain_input_column_context) and injects them, so the explanation grounds each SQL input in its meaning.result["input_meanings_resolved"]. - Reproduction-grade parameters (E): parses the whole
TDS_MODEL_REPOSITORY.ARGUMENTS(addssto_parameters— column typing +output_format); reads the training grain fromTDS_MAPPER_REPOSITORY(model_structure:id_row/id_partition/id_fold/dataset); and parses the training STO view DDL via new_parse_sto_invocation(sto_invocation:partition_by/order_by/script_command/returns/charset). The LLM prompt (now the DB-free seam_algorithm_system_prompt) frames these as a tdstone2HyperModelconfiguration recipe, grounded in the real package API. All bounded + fail-open.
Changed (classifier clarity — DEFINITION vs EXPLAIN vs ALGORITHM)¶
Tightened the ca-definition / ca-explain / ca-algorithm SKILL.md descriptions and
classifier examples so DEFINITION=meaning, EXPLAIN=formula/"how computed" (the cheap path),
and the SQL-intensive ALGORITHM fires ONLY on explicit ML-model/algorithm/code/package/
parameter requests. "How is X computed / what is the formula" always routes to EXPLAIN, even
for ML-backed features.
Tests: tests/test_explain_formula_closure.py, tests/test_explain_alias_resolution.py,
tests/test_algorithm_params.py.
[0.3.1.95] - 2026-06-16¶
Added (consumer agent — ALGORITHM skill: explain the ML model behind a feature)¶
A new ALGORITHM intent/skill answers data-scientist questions about the machine-learning model behind an ML-backed feature: which algorithm, which Python packages, the model code, the hyperparameters, how many partitions were trained. It is distinct from EXPLAIN (which explains the feature's SQL formula) — ALGORITHM explains the ML model.
ML features are produced by tdstone2 via a Teradata Script Table Operator (STO).
skill_algorithm (agent/consumer_agent.py) walks the
tdfs4ds → tdstone2 metadata chain entirely via tdml.execute_sql:
- Resolve the feature → its process view (same resolver as EXPLAIN) →
SHOW VIEWDDL. - Detect the STO (
_is_sto_backed) and parse the scoring-mapper UUID from theTDS_STO_SCORING[_LIVE]_<uuid>reference (_parse_sto_mapper_uuid). A non-ML (SQL-rule) feature returnserror=not_an_ml_featurewith a message pointing the user to EXPLAIN. TDS_MAPPER_REPOSITORY(ID = uuid) → trained-models table; a capped DISTINCT join (_build_distinct_model_join_sql, ≤tdfs4ds.ALGORITHM_MAX_CODES=20) resolvesID_TRAINED_MODEL→ID_MODEL→ID_CODE+ARGUMENTSin one round trip.ARGUMENTSJSON → hyperparameters +column_names_X(model inputs). The CODE blob is read only when the question is about code/packages/algorithm internals, viaFROM_BYTES(CODE,'ascii')(_read_code_blob; a plainCAST(... AS VARCHAR)errors), truncated totdfs4ds.ALGORITHM_CODE_MAX_CHARS=8000; packages via_extract_packages_from_code.- Trained-models summary (
_read_trained_models_summary) reports partition count + STATUS. Freshness caveat: whenTD_TIMECODEis the open-end sentinel (year ≥ 9999,_is_sentinel_timestamp) the exact training date is reported as unavailable — never printed as9999-01-01. Exact package versions are not stored in metadata; the answer says so. - The skill returns its final
answerdirectly (no second LLM pass).
MCP-gated availability: the ALGORITHM intent is registered in the graph/classifier only
when mcp_enabled=True (build_graph adds local_intent_map["ALGORITHM"] under the gate;
the existing intent filter then keeps it out of the dynamic Pydantic Literal when MCP is
off). When MCP is off, an algorithm-shaped question is detected deterministically
(_looks_like_algorithm_question) and answered with a fixed "enable MCP" message (EN/FR) via
the ALGORITHM_UNAVAILABLE pseudo-intent.
New files/config: tdfs4ds/skills/ca-algorithm/SKILL.md; tdfs4ds.ALGORITHM_MAX_CODES,
tdfs4ds.ALGORITHM_CODE_MAX_CHARS. Tests:
tests/test_algorithm_{sto_parse,packages,sentinel,join_sql,gating}.py.
[0.3.1.94] - 2026-06-16¶
Fixed (consumer agent — multi-feature DEFINITION misrouted to RECAP; "(from flags)" annotation broke lookup)¶
A multi-feature question that LISTS named features — e.g. "what are these features: flag_provider_zscore_3 (from flags), provider_z_score (from flags), …" — was misclassified as RECAP (returning "no features in conversation context") instead of DEFINITION. The "what are these features" phrasing collides with the RECAP few-shots/the "these"-pronoun hint, and a small classifier (ministral-14b) anchors on it and discards the explicit list. The trailing "(from flags)" annotation additionally broke the exact-match catalog lookup.
Fixes (in agent/graph.py and agent/consumer_agent.py):
- Deterministic RECAP→DEFINITION guard in _node_classify_impl: when the question
lists ≥2 known catalog feature names (or ≥1 after a colon), re-route to DEFINITION and set
object_name to the matched names. A token counts if it EXACTLY matches a catalog name OR
fuzzy-matches one with high confidence (tdfs4ds.FUZZY_NAME_MATCH_CUTOFF, default 0.90),
so a single typo in the list still routes correctly. Typo'd tokens are passed as typed,
so skill_definition_multi's existing per-element correction emits "did you mean …?". Exact-
or-high-cutoff gating keeps a genuine RECAP ("what did we discuss?") from being hijacked.
- _clean_object_name extended to also strip ONE trailing parenthetical annotation
(provider_z_score (from flags) → provider_z_score), applied per comma-split element
at every multi-name exact-lookup node (node_skill_definition, node_skill_lineage,
node_recognize_entity). Interior characters are never touched (names have no parens;
composite grain claim_id,region is unaffected).
- Size-adaptive fuzzy backend _best_name_similarity (the user's scale point): Python
difflib for small per-turn-cached catalogs (zero DB round-trip — the consumer-agent case),
Teradata in-database StringSimilarity (jaro_winkler) above
tdfs4ds.FUZZY_INDB_THRESHOLD (default 500) for large catalogs.
- Prompt hardening (both classifier paths + ca-definition/SKILL.md): DEFINITION now
states "one or more, comma-separated; an explicit list is DEFINITION not RECAP"; RECAP
states "only when no explicit names are listed"; plus a contrasting few-shot.
Verified live: the failing question now defines all 5 features (no "(from flags)" leakage),
a typo'd element ("provdr_z_score") re-routes and surfaces the "did you mean" correction, a
real RECAP stays RECAP, and the prior 7-turn session is unchanged. Covered by
tests/test_recap_definition_routing.py and tests/test_clean_object_name.py.
[0.3.1.93] - 2026-06-16¶
Fixed (consumer agent — EXPLAIN crashed in the streaming / chatbot path)¶
Asking an EXPLAIN question in the Gradio chatbot (e.g. "How is provider_z_score
calculated? Walk me through the formula.") returned "An error occurred: 'NoneType'
object is not iterable". The streaming entry point consumer_agent_stream
(agent/graph.py) iterates graph.stream(…, stream_mode="updates")
and did final_state.update(node_delta) for every node. The EXPLAIN path includes the
no-op trace node node_pre_skill_explain which returns {}; LangGraph surfaces an
empty-dict update as a None delta in updates mode, so final_state.update(None)
raised and aborted the whole turn before skill_explain ran. (Pre-existing — only
the streaming path is affected; consumer_agent() via .invoke always handled EXPLAIN
correctly. Not a regression from 0.3.1.91/0.3.1.92.)
Fixed by guarding the merge: if node_delta: final_state.update(node_delta) — empty/None
deltas are skipped (state is unchanged; the real work node runs next) while the trace step
is still emitted. EXPLAIN now returns the full formula walkthrough through the chatbot,
identical to the .invoke path. Covered by tests/test_stream_none_delta.py; verified
live across a 7-turn session (overview → counts → search → definition → explain →
compare → explain).
[0.3.1.92] - 2026-06-16¶
Fixed (consumer agent — DEFINITION failed on markdown-quoted feature names)¶
A DEFINITION question that wrapped the feature name in markdown backticks or quotes —
e.g. "What exactly is provider_z_score?" — could return "feature not found … did
you mean …?" instead of the definition. The LLM intent classifier sometimes returns
object_name with the wrappers kept (`provider_z_score`), and every catalog
lookup is an exact case-insensitive match, so the wrapped name matched nothing and the
skill fell through to the fuzzy near-miss path. (Pre-existing fragility, exposed by
classifier nondeterminism — not introduced by 0.3.1.91.)
Fixed at the single chokepoint: _node_classify_impl (agent/graph.py)
now passes the classifier's object_name, the explicitly-named entity_name, and each
low-confidence interpretation's object_name through a new _clean_object_name helper
that strips surrounding backticks / single / double quotes / whitespace only (interior
characters — _, digits, and composite-grain commas like claim_id,region — are
untouched, so matching is not loosened). Every downstream skill now receives a clean name.
Covered by tests/test_clean_object_name.py; verified live (the quoted question now
defines correctly, while a genuinely missing feature still gets the graceful "did you
mean" response).
[0.3.1.91] - 2026-06-16¶
Performance (consumer agent — collapsed N+1 query loops, per-turn SQL memo)¶
Multi-feature questions (summary, multi-feature definition, feature-group freshness, lineage) issued one Teradata round-trip per feature / entity / process / dependency node in loops, and re-materialized whole catalog / business-dictionary tables several times within a single turn. Measured live on the demo feature store, four representative turns went from 232 → 67 SQL round-trips (−71%) with identical answers (e.g. domain summary 61→7, 3-feature definition 91→18).
- Batched business-dictionary helpers (
_query_bd_columns/_query_bd_sections/_query_bd_objectsin agent/consumer_agent.py) now accept list kwargs (table_names/column_names/object_names) and emit a singleUPPER(col) IN (…)predicate via the shared_in_clausebuilder. The singular kwargs remain back-compatible. _get_feature_catalog_with_docsrewritten from per-entityget_list_features+ per-feature_query_bd_columns+ a full all-domainsprocess_catalogmaterialization (tens of round-trips) to ~3 scoped reads: one feature-catalog read, one batchedget_feature_versionsper entity, one scopedPROCESS_ID IN (…)map (_processes_by_id), one scopedBD_COLUMNS IN (…)read joined in pandas._build_multi_object_context,skill_lineage, and both freshness paths now issue one batched query for all views / datasets / dependency nodes / process_ids instead of looping. Newfollow_up_report_many(process_ids)in process_store/process_followup.py fetches all follow-up history in onePROCESS_ID IN (…)query.- SUMMARY no longer scans the whole
BD_SECTIONS/BD_OBJECTStables — the reads are scoped to the in-scope process views and registered datasets. - Per-turn SQL memo (
_TURN_CACHE/_turn_cached, reset at the start of every turn innode_classify): scoped catalog / BD reads, and the unavoidable whole-store scans used for object-name resolution (_all_processes_cached/_all_features_cached), are materialized at most once per turn. Strictly intra-turn — the cross-turnaccumulated_context_graphcache is unchanged.
Behaviour is preserved: same aggregates, same answers. Covered by DB-free unit tests in tests/test_agent_query_batching.py.
[0.3.1.90] - 2026-06-15¶
Fixed (pyvis lineage graph — tables and datasets misclassified as plain views)¶
FS_LINEAGE_GRAPH.PARENT_OBJECT_TYPE / CHILD_OBJECT_TYPE are CHAR(1) columns. Teradata pads fixed-width CHAR values, so the Python driver returns "T " / "D " instead of "T" / "D". The lookup {"V":…, "T":…, "D":…}.get("T ", "view") fell through to the "view" default in get_persisted_graph, making every table and dataset node appear as a plain (blue) view in the pyvis diagram. Fixed by stripping whitespace from both type columns immediately when loading rows in load_lineage_digraph.
Added (pyvis lineage graph — legend)¶
A fixed-position legend (bottom-left corner, semi-transparent white card) is now injected into the generated HTML, showing the four node types with their colors: orange (process view), blue (plain view), green (table), purple (dataset).
[0.3.1.89] - 2026-06-15¶
Changed (pyvis lineage graph — three-tier node coloring)¶
The interactive lineage graph (plot_lineage_pyvis) previously used a single color for all view nodes, making it impossible to distinguish feature-engineering process views from plain intermediate views at a glance.
- Three-tier color scheme in
_PYVIS_COLOR(lineage/lineage_store.py): - Blue
#6baed6— plain / intermediate view (not a registered process) - Orange
#e6550d— registered process view (present inFS_V_PROCESS_CATALOG) - Green
#31a354— source table - Purple
#756bb1— dataset node - Process-view resolution:
plot_lineage_pyvisnow queriesprocess_catalog(all_data_domains=True)once before the node loop and builds a(db, obj)lookup set. Each"view"node is classified as"process"or"plain view"against that set. Degrades gracefully (falls back to plain-view color) when the catalog is unavailable. - Tooltip labels updated to
"type: process view"/"type: plain view"/"type: table"/"type: dataset".
[0.3.1.85] - 2026-06-14¶
Fixed (lineage graph — valid-time temporal writes, no history churn)¶
FS_LINEAGE_GRAPH is a valid-time temporal table, but record_process_lineage wrote it with a blind CURRENT VALIDTIME DELETE + INSERT of every edge on each call — so re-running on an unchanged graph closed and re-opened identical rows, inflating the validity history (observed: 46 active edges accumulating to 952 rows across repeated rebuilds). A row's ValidPeriod must reflect when an edge actually existed, not when the rebuild happened to run.
- Diff-based writes (
record_process_lineage): read the current edges for the parents in the new graph, then onlyDELETEedges that disappeared,INSERTedges that are new, andUPDATEtheOBJECT_TYPEof matched edges whose type changed. Unchanged edges are left untouched (theirValidStartis preserved). Re-running on an unchanged graph now writes zero rows. Returns the number of edge changes (0 = no change). rebuild_lineage_graphno longer wipes. The unconditionalCURRENT VALIDTIME DELETE FROM <table>at the start of a full refresh was removed (it defeated the diff by making every edge look new). Pruning of edges left by since-dropped objects is now a targeted, in-memory desired-set diff (prune=True, default) that closes only the edges no longer produced by any seed — never re-opening still-valid ones.- Teradata syntax fix: the existing-edges read used the unsupported row-constructor predicate
WHERE (a, b) IN ((..),(..))(which threw, leaving the diff empty so everything re-inserted); replaced with a flatWHERE UPPER(PARENT_OBJECT_NAME) IN (...)+ a Python(db, obj)re-check.
Verified live on the demo: rebuild from empty → 46 edges; rebuild ×2 more → 0 changes, table stays 46/46; delete one edge then scoped-refresh → exactly 1 change (re-inserted), confirming real changes are still detected.
Changed¶
- Demo notebooks (
claims-anomaly-detection) now calltdfs4ds.connect(..., create_if_missing=True)so the lineage graph (and all catalog tables) auto-create on a fresh schema and the write hook always has its table.
[0.3.1.84] - 2026-06-14¶
Added (persisted, temporal lineage graph)¶
Lineage was previously dynamic and downstream-only — every query re-derived the graph by walking SHOW VIEW / SHOW TABLE DDL, and there was no way to cheaply ask "what depends on this?". This release persists the graph so both directions are available, and drives documentation from it.
FS_LINEAGE_GRAPH— new VALIDTIME temporal edge table (one row per directedparent → childobject edge; nodes reuse the business-dictionary identity(DATABASE_NAME, OBJECT_NAME)+OBJECT_TYPE; noPROCESS_IDon edges — an edge connects objects, not processes). Public viewFS_V_LINEAGE_GRAPH. Created when missing atsetup()/connect(create_if_missing=True). New module tdfs4ds/lineage/lineage_store.py.- Write hook —
upload_featuresnow records the process view's lineage subgraph (record_process_lineage) after the optimize/document step (non-fatal). Edges come from the existingbuild_teradata_dependency_graph(so feature-store technical tables are skipped and datasets are nodes); refresh is idempotent per parent object. Seeds from the view'sSHOW VIEWDDL, notSELECT * FROM view— a plain SELECT has no target and yields 0 edges. tdfs4ds.rebuild_lineage_graph(objects=None, all_data_domains=True, check=True)— full refresh (wipe + re-derive from every process view + dataset) or scoped refresh of just the objects you edited (no wipe; runs the impact check after).- In-memory query layer (networkx) —
load_lineage_digraphreads the table once into aDiGraph;get_descendants(impact / "what depends on this") andget_ancestors(upstream sources) traverse in memory. Falls back to an on-the-fly build when the persisted table is empty/unavailable, so query/visualisation always works (logs a hint to runrebuild_lineage_graph()). tdfs4ds.genai.rebuild_documentation(objects=None, force_update=False)— document the whole store from the persisted graph (topo-sorted leaves-first, shared visited-sets → each process/dataset documented exactly once), or re-document only the impact set of edited objects. Shares the new_document_from_graphwalk withdocument_feature_store_incremental.tdfs4ds.check_downstream_processes(objects)— deterministicEXPLAIN SELECT * FROM <view>(compile-only) on every registered process downstream of a change, so a changed view's result-column change that breaks a consumer is caught: returns{checked, ok, broken:[{view, process_id, error}]}.tdfs4ds.resolve_objects(objects, require_exists=True)— identity-resolution helper: normalises"db"."NAME"/db.name/NAME(any case) and verifies against the graph thenDBC.TablesV, raising on any unmatched name (no silent no-op).plot_lineage_pyvis(graph, output_path=...)— interactive HTML network for the persisted graph (UTF-8 safe on Windows).- Opt-in cascade re-documentation —
upload_features(..., cascade_redocumentation=True)re-documents downstream processes/datasets, but only when the process's own documentation actually changed (document_process_incrementalnow reports_doc_changed). - Consumer agent — impact direction —
ca-lineage/skill_lineagenow also answers "what depends on / consumes / would be impacted by this feature?" (newimpacted_objectsfrom the persisted graph). New developer skillfs-lineage-graphcovers the admin operations (rebuild, scoped refresh, impact check, re-document, pyvis).
Fixed¶
resolve_process_id_for_objectread the process catalog via a hand-rolledCURRENT VALIDTIME SELECT … FROM FS_V_PROCESS_CATALOG, which errors on some installs (the public view already applies validtime) and returnedNonefor every process. Now reads viaprocess_catalog().to_pandas()— also fixes the cascade resolver.
[0.3.1.81] - 2026-06-13¶
Fixed (dataset catalog — two registration bugs surfaced by the 0.3.1.79 split)¶
Re-running build_dataset for a flat view (ds_investigation_queue) under the corrected entity/feature split (0.3.1.79) exposed two latent DatasetCatalog bugs. Both were masked before the split fix because flat views previously had an empty dropped_entity set.
add_datasetdeleted the features it had just inserted. The feature-cleanup loop iterateddropped_entityinstead ofdropped_features, so every column moved out of the entity grain by the new split was valid-time-closed in the FEATURES table seconds after being MERGE-inserted. Result: the dataset registered with the correct 2-key entity but 0 current features (verified live: 16 feature rows each living ~4 s before being re-closed). One-word fix: iteratedropped_features. After the fix,ds_investigation_queueregisters cleanly as 2 entity / 16 features.drop_datasetsilently did nothing. (1) The guardif self.catalog[…].shape[0] == 1:read a stale snapshot bound in__init__and had noelse, so a miss dropped nothing with no log or error. (2) It onlyCURRENT VALIDTIME DELETEd (soft close), with no way to purge closed history. (3) The module-leveldrop_dataset()wrapper constructedDatasetCatalogwith the defaultname='DATASET'→ wrong tables (FS_DATASET_*instead ofFS_FS_DATASET_*). Fixed: re-query the catalog view fresh, raiseValueErroron a miss, addpurge=True(NONSEQUENCED VALIDTIME DELETE) for a full reset, and passname=tdfs4ds.DATASET_CATALOG_NAMEin the wrapper.
Live-verified against the demo: T10 (score-direction note agrees with EXPLAIN), T12 (pharmacy_z_score surfaced), and T15 (dashboard uses the real ds_investigation_queue, CASE-orders risk_band, reports provider/pharmacy as uncovered rather than fabricating a join) all pass.
[0.3.1.80] - 2026-06-13¶
Version bump to ship the dataset-registration fix (0.3.1.79) and the consumer-agent / serve / reconnect work (0.3.1.77–78). Re-run the affected build_dataset calls to re-register datasets with the corrected entity/feature split.
[0.3.1.79] - 2026-06-13¶
Fixed (dataset registration — authoritative entity/feature split)¶
build_datasetnow registers the entity/feature split it already knows, instead of re-deriving it from the dataset view's DDL.Dataset._retrieve_entities_and_featuresclassified a column as a FEATURE only when the view DDL matched the canonical feature-store subquery (B1.FEATURE_VALUE AS … WHERE FEATURE_ID=… AND FEATURE_VERSION=…) and treated everything else as an ENTITY. A flat / non-canonical dataset view therefore registered 0 features with every column as an entity (observed:ds_investigation_queue). The role of a column is dataset-specific (the same name may be a feature in one process and an entity in another), so it must come from the build, not the catalog or DDL.Dataset.__init__gained optionalentity=/features=parameters; when both are supplied they are used verbatim and the DDL-regex path is skipped.build_datasetcomputes the split via the newcompute_dataset_split(entity_id, selected_features)(same column aliases the view builder emits) and fills SQL types from the built view's columns. Backward compatible:Dataset(view_name=…)/Dataset(df=…)without the split still use the regex derivation;build_datasetfalls back to it if the split can't be computed.
[0.3.1.78] - 2026-06-13¶
Follow-ups from the live demo replay of the T10/T12/T15 fixes, plus a connection-resilience tool.
Fixed (consumer agent — dashboard grounding, live-verified)¶
- Entity columns now drive the dataset cover (T15).
skill_compose_dashboardread the wrong catalog column (ENTITY_ID; the live catalog usesENTITY_NAME) soavailable_entitieswas always empty and provider/pharmacy never reached the cover. Fixed to useENTITY_NAME, and_build_dataset_entity_keysnow reads the actualENTITYcolumn returned byget_dataset_entity(was probing non-existent names → empty join keys)._extract_feature_list_from_questiongained a deterministic entity-key fallback (scans the question for known entity stems when the LLM returns none). - Cross-grain identifiers handled honestly.
_cover_features_with_datasetsonly JOINs an entity-providing dataset when it shares a key with the chosen set; different-grain stats datasets (e.g.provider_id-only) are reported ascross_grain(rendered as a grain caveat) instead of being force-joined or triggering a false "cannot compose". The dashboard now answers with the real claim-grain dataset, CASE-ordered band, and projected entity ids. - MCP no longer bypasses the grounded dashboard path. With MCP enabled, dashboard-shaped questions classified as
DATA_QUERYpreviously routed to free-form MCP SQL (which fabricated columns / usedORDER BY band DESC/LIMIT).node_skill_mcp_toolsnow short-circuits dashboard requests toskill_compose_dashboard, andsynthesize_answerroutesDATA_QUERYresults carryingdashboard_intentthrough the grounded R5 renderer. MCP-on and MCP-off now produce the same grounded answer.
Added (Teradata reconnection)¶
tdfs4ds.reconnect_teradata()— re-establishes the teradataml context fromTDFS4DS_TD_*env /.envcredentials (tdfs4ds/utils/reconnect.py).execute_querynow auto-detects a dropped-connection error (is_connection_error), reconnects once, and retries the query transparently; the helper is also callable manually. SQL/logic errors are not misclassified as connection errors.
[0.3.1.77] - 2026-06-13¶
Consumer-agent quality fixes from the business-user-journey eval (T10, T12, T15). New deterministic helpers replace fragile LLM/substring heuristics; all are pure and unit-tested in tests/test_agent_pure_helpers.py (no Teradata needed).
Fixed (consumer agent — dashboard query synthesis, eval T15)¶
- Grounded dashboard composition.
synthesize_answer's dashboard path no longer lets the LLM pick the dataset, the ranking column, or invent a join. New pure helpers do it deterministically:_choose_ranking_metric()prefers a numeric risk/score column (inverted scores →ORDER BY … ASC) and, when only a categorical band is available, emits aCASE-expression ordering instead of an invalidORDER BY risk_band DESC;_cover_features_with_datasets()greedily covers the requested features across datasets and derives the JOIN keys from the shared entity columns. When no requested feature is exposed, columns are reported as unavailable rather than backed by a fabricated dataset. The dataset allow-list is verified against_known_catalog_names(), and the LLM is constrained to present the Python-composed SQL verbatim.
Fixed (consumer agent — SEARCH feature omission, eval T12)¶
- Depluralizing, entity-aware token matching.
skill_search's catalog pre-filter previously substring-matched question tokens againstFEATURE_NAME + DATA_DOMAINonly, with no stemming — so "pharmacies" missed the featurepharmacy_z_score. New_normalize_tokens()/_depluralize()map plurals to singulars,_row_token_set()also matches the entity name, and matches are bidirectional prefix-aware (provider↔provider_id). Matched rows are now ranked by match strength so the strongest survive theSEARCH_CATALOG_MAX_ROWScap.
Fixed (consumer agent — score-direction contradiction, eval T10)¶
- DEFINITION ↔ formula reconciliation. DEFINITION trusted the stored business description, which could claim "higher = riskier" while the formula inverts the score (
1 - …, so 0 = highest risk) — contradicting EXPLAIN. Newdetect_score_inversion()(shared single-source util ingenai/documentation.py) +_direction_correction_note()append an authoritative "lower = higher risk" clarification when the prose contradicts an inverted formula. The DDL fetch is gated on a cheap prose check so the cache fast-path stays O(1) for non-risk features.
[0.3.1.76] - 2026-06-13¶
Added (HTTP server — tdfs4ds[serve])¶
- OpenAI-compatible API + LangServe playground — new optional
tdfs4ds.servesubmodule (pip install tdfs4ds[serve]) exposes both agents over HTTP. A single FastAPI app servesGET /v1/models+POST /v1/chat/completionswhere themodelfield selects the agent (tdfs4ds-consumer→consumer_agent,tdfs4ds-query-optimizer→query_optimizer_agent), so any OpenAI client can drive them. The query optimizer takes its SQL inline in the chat message. LangServe routes (/consumer,/query-optimizer) are mounted alongside for the interactive playground. - Run modes —
python -m tdfs4ds.serve, or the new rootDockerfile/docker-compose.yml(docker run --env-file .env -p 8000:8000 tdfs4ds-serve). Configuration is entirelyTDFS4DS_*env vars /.env; the newTDFS4DS_TD_HOST/_TD_USERNAME/_TD_PASSWORD(+ optional_TD_LOGMECH,_TD_DATABASE) open the Teradata context at startup, andTDFS4DS_SERVE_BUILD_INDEXtoggles the vector-index pre-build. See.env.exampleand the new docs page Deployment → OpenAI-compatible API server. - Conversation continuity maps the OpenAI
userfield to the agent'sthread_id(stateless per request otherwise);stream: trueis supported (single content chunk +[DONE]).
Changed (query optimizer agent — report as a link, not in the answer)¶
- OPTIMIZE answers are now a compact summary + a downloadable report link.
query_optimizer_agent's OPTIMIZE path previously returned the entire Markdown report as the answer. It now writes the full report to disk (underQUERY_OPTIMIZER_REPORT_DIR/<DATA_DOMAIN>/, same naming convention as theupload_featuresoptimizer) and the answer carries only the score delta, optimized SQL, FilterManager applicability, and a reference to the report (report_pathon the skill result; the full text stays available asreport_full). Benefits every caller — the OpenAI API, the LangServe playground, and notebooks. tdfs4ds.serveexposes the reports for download.GET /reportslists available reports andGET /reports/{path}downloads one (path-traversal-guarded). The OpenAI/v1/chat/completionsresponse rewrites the relativereport_pathinto an absolute…/reports/…URL using the request's base URL.
Added (admin chatbot app — launch_chatbot_app)¶
launch_chatbot_app()— new additive wrapper intdfs4ds.agent.chatbotthat embeds the existing consumer-agent chatbot under a collapsible ⚙️ Parameters panel for managing, live: the feature store database (switch validated byconnect(..., create_if_missing=False)— a DB without a feature store is rejected, no switch), the vector store (backend + Chroma path + a Refresh/rebuild button), and the instruct / embedding model settings (provider, model, URL, API key, proxy, dimension). Applying a data/model change rebuilds the index when needed, callsreset_consumer_agent(), rotates the thread, and clears the chat. The chat UI is factored into a shared_build_chat_uihelper;launch_chatbot/launch_chatbot_with_indexare unchanged (backward compatible).- Served on a second port in Docker.
python -m tdfs4ds.servenow also launches the admin chatbot in the same process (shared Teradata context + index), onTDFS4DS_SERVE_CHATBOT_PORT(default 7860), toggled byTDFS4DS_SERVE_CHATBOT(default true). TheDockerfile/docker-compose.ymlexpose7860alongside8000. - Per-user feature-store database = one container per user. Configuration (
SCHEMA+ catalog globals + Teradata context) is process-wide, so the panel/DB switch applies to everyone in a process. Documented guidance: for per-user databases, run a separate container per user (same Teradata login, differentTDFS4DS_SCHEMA) — no code change, no per-session isolation within a process.
Added (outbound proxy for LLM / embedding calls — HTTP or SOCKS5)¶
INSTRUCT_MODEL_PROXY(withEMBEDDING_MODEL_PROXYfalling back to it) routes the OpenAI-family LLM and embedding HTTP clients through a proxy; the URL scheme selects the protocol (http://…orsocks5://…), empty = direct. Injected via an explicithttpxclient (http_client=/http_async_client=) so it stays scoped to those calls and never touches the Teradata connection.socks5://support comes from the newhttpx[socks]dependency. Configurable via env /.env/ JSON and live in the chatbot admin panel.
[0.3.1.64] - 2026-06-11¶
Consumer-agent reasoning fixes driven by the LLM-as-judge evaluation run of 2026-06-11 (scenarios E01, E06, E10, S01).
Fixed (consumer agent — hallucinated lineage, eval E06)¶
- Degenerate lineage cache hit — after a DEFINITION turn the accumulated context knows only
feature → process_view(no upstream objects)._query_accumulated_context_for_lineage()served this as a cache hit, the LINEAGE fast path skipped the real DDL scan, and the LLM — given a dependency list containing only the process view itself — confabulated source table names. The helper now returns a cache miss when the process view is absent fromsource_viewsor has an emptydepends_on, routing the question to the realbuild_teradata_dependency_graphscan. The fast-path LINEAGE prompt additionally forbids inventing object names and instructs the LLM to say so when no upstream is listed.
Added (consumer agent — explicit entity short-circuit, eval E10)¶
entity_nameextraction in the intent classifier — bothIntentResultand the dynamic intent model now carry an optionalentity_name; the classifier prompt extracts an explicitly named entity/granularity ("total_amount for pharmacy_id")._node_classify_implwrites it toresolved_entity_name(cleared on turns with no explicit entity), which has top priority in the_hint_entitychain — so an ambiguous feature name resolves without a clarification round-trip when the user already said which entity they mean._entity_from_question()deterministic safety net — before raisingambiguous_feature,skill_definition,skill_freshness,skill_lineage, andskill_explainscan the raw question for the candidate entity names (case-insensitive,_/space-normalised, multi-key entities match on any key component) and auto-resolve when exactly one matches.- Deterministic
ambiguous_featureclarification — the clarification message insynthesize_answeris now a template (EN/FR via the new_question_is_french()heuristic) instead of an LLM call, which was observed embellishing the clarification with a guessed generic definition of the feature.
Added (consumer agent — typo recovery, eval E01)¶
- Classifier rule: intent follows question shape — "what is X" classifies as DEFINITION even when X matches no known object (it may contain a typo); previously the unfamiliar token flipped the intent to SEARCH whose generic synthesis answered speculatively without touching the business dictionary.
- Single-candidate fuzzy auto-correct — when
_fuzzy_match_featurereturns exactly one candidate,skill_definitionandskill_freshnessnow answer for it directly with acorrection_note("'ml_rsk_scre' was not found — answered for the closest match 'ml_risk_score'") instead of a did-you-mean round-trip — mirroring the existing behaviour ofskill_lineage/skill_explain. Multiple candidates keep the did-you-mean prompt.
Added (consumer agent — answer verifiability, eval S01)¶
grounding_contexton DEFINITION results —skill_definitionreturns the raw retrieved evidence (context_parts) the explanation was generated from, so the trace panel and LLM-as-judge evaluations can verify the answer is grounded rather than scoring a fully-grounded answer as unverifiable.
Fixed (consumer agent — crashes in the 0.3.1.63 work)¶
consumer_agent(return_context=True, return_trace=False)crashed withUnboundLocalError: trace_md; it now returns the documented 2-tuple(answer, accumulated_context_graph)(the 0.3.1.63 note has been corrected accordingly)._build_context_update()crashed withUnboundLocalError: _feat_cache_keywhen a skill result carried acontext_graphbut noresolved_triplet(the import was nested inside the single-feature branch); the import is now function-level.
[0.3.1.63] - 2026-06-10¶
Changed (consumer agent — accumulated context keying)¶
- Composite feature cache keys —
accumulated_context_graph.featuresis now keyed byFEATURE_NAME::ENTITY_NAME(both uppercased) instead of a bareFEATURE_NAME, so the same feature name registered under two entities no longer collides in the cross-turn cache. New helpers_feat_cache_key()and_feat_cache_lookup()(inconsumer_agent.py) centralise key construction and a 3-tier lookup: exact composite →FEATURE::prefix scan → legacy plain-FEATUREkey (backwards-compatible with older snapshots). All six_query_accumulated_context_for_*fast-path helpers and bothskill_definitionfast-paths route through_feat_cache_lookup. - Uppercase-keyed context sections —
_merge_accumulated_context()(graph.py) now normalises keys to uppercase for thefeatures,datasets,entities, andsource_viewssections for stable lookup.processesandfreshness_timelinestay keyed by their process-id UUIDs.
Added (consumer agent — EXPLAIN feeds the context graph)¶
- EXPLAIN now accumulates context —
_skill_explain_multi()returns acontext_graph(one entry per explained feature, carryingprocess_id+view_name) and aresolved_tripletfocus pointer;node_skill_explainmerges this via_build_context_update/_merge_accumulated_context, mirroringnode_skill_definition. Follow-up turns after an EXPLAIN can therefore resolve from cache.skill_explainnow also capturesPROCESS_IDper feature.
Added (consumer agent — entity-scoped SUMMARY)¶
skill_summary(entity_filter=…)— a newentity_filterargument (and a newentity/data_domainrecognition path in_recognize_object_type) scopes the summary to a single entity, answering "what features belong to entity X?".node_skill_summaryderives the entity from conversation state and passes it through.
Changed (consumer agent — API signature)¶
consumer_agent(return_context=True)returns(answer, accumulated_context_graph); combine withreturn_trace=Truefor the 3-tuple(answer, trace_md, acc_ctx).return_trace=Truealone is unchanged ((answer, trace_md)). (Corrected in 0.3.1.64 — this entry originally claimed a 3-tuple forreturn_context=Truealone, which never matched the docstring, tests, or code.)
[0.3.1.59] - 2026-06-10¶
Added (consumer agent — conversation-management intents)¶
- RECAP / OUT_OF_SCOPE intents now actually fire — these built-in intents were wired into the graph but had no classifier few-shot examples, so "what are the features we are talking about?" mis-routed to SUMMARY.
_build_classifier_examples()now injects explicit high-confidence examples (3 RECAP variants, 2 OUT_OF_SCOPE variants) plus a contrasting SUMMARY/RECAP pair that teaches the scope-of-data boundary (SUMMARY = full catalog that exists, queries Teradata; RECAP = only what was already discussed, reads memory).build_graph()injects descriptions for both built-in intents into the dynamic Pydantic intent model and the classifier prompt.
Added (consumer agent — entity-clarification recovery)¶
- Deterministic recovery from ambiguous-feature replies — when
skill_definitionreturnserror="ambiguous_feature"(a feature name exists under multiple entities),node_skill_definitionnow raises a trackedawaiting_feature_entity_clarificationpending action (via the new_ambiguous_feature_pending()helper) carrying the valid entity list, the original feature, and the origin intent. The next reply is resolved deterministically by_node_classify_implthrough the existing_resolve_clarification_choice(digit / ordinal / fuzzy) — the LLM classifier is short-circuited entirely. - Role separation + intent inheritance: the entity is taken from the reply while the feature (
object_name) and intent (DEFINITION) are inherited from stored context, so mixed-concept replies ("the total_amount feature of the provider_id entity") and a bare "1" both resolve correctly. The chosen entity is written to a newresolved_entity_namestate field (preferred over_focus_entity) and intoaccumulated_context_graph._focus_entity. - The
ambiguous_featuresynthesis prompt now renders a numbered list so a digit reply maps 1:1 to stored options. - Scope: DEFINITION only;
node_skill_lineagekeeps its existing early-return. Deferred: a general "feature X of entity Y" phrasing arriving with no prior pending state still can't role-separate (IntentResulthas noentity_namefield yet).
[0.3.1.41] - 2026-06-08¶
Changed (chatbot UI — conversation management)¶
- Separate Clear and Reset buttons: The single "Clear conversation" button is now split into two distinct actions:
- Clear Conversation — resets visual display (chat history, trace, viz panel, step indicator, message input) but preserves the LangGraph thread_id, so
accumulated_context_graphand all agent memory persist. Use this to refresh the UI while maintaining context across turns. - Reset Conversation — resets visual display and rotates to a new thread_id UUID, starting a fresh LangGraph session with no prior context. Use this for a complete restart.
- Gradio UI enhancement — the "Reset Conversation" button uses
variant="stop"for a red/destructive visual cue to distinguish it from the safer "Clear" action.
[0.3.1.38] - 2026-06-02¶
Changed (skills — semantic collision fixes + v0.3.1.35–37 content updates)¶
- Semantic collision disambiguation — added
SKIP when … use <skill> insteadclauses to 10 SKILL.md files so Claude Code selects the right skill in ambiguous situations: td-explain↔qo-skill-explain(both analyze EXPLAIN plans; differ in output format and workflow role)ca-lineage↔fs-lineage(both surface lineage; differ in audience and output — NL Q&A vs Python code + Sankey diagram)ca-dataset↔ca-build-dataset↔fs-dataset(discovery vs agent-driven creation vs code generation)ca-explain↔ca-definition(SQL formula → plain language vs business meaning)fs-inspect↔fs-analyze(catalog audit vs single-process performance analysis)fs-setup/SKILL.md— added Vector Store Backend optional config block:VECTOR_STORE_BACKEND,TERADATA_VECTOR_STORE_PREFIX, and all fiveTERADATA_AI_*globals (v0.3.1.37).fs-agent/SKILL.md— added Option B (Teradata Enterprise Vector Store),AGENT_ANSWER_STYLE, and a Chart rendering section describing automatic Chart.js → Plotly conversion for teradata-mcp-serverplot_*tools (v0.3.1.35–37).fs-document/SKILL.md— addeddocument_view_incremental()step, formula consistency validation columns table (MONOTONIC_DIRECTION,VALUE_DOMAIN,IS_ABSOLUTE_VALUE,FORMULA_CONSISTENCY_WARNING), andmigrate_documentation_columns_schema()migration snippet (v0.3.1.35).
Added (skills — structural completeness)¶
.claude/skills/expanded from 11 → 24 directories: all 10ca-*skills andqo-skill-explain,qo-skill-optimize,qo-skill-simplifycopied fromtdfs4ds/skills/.tdfs4ds/skills/td-analytics-workflow/(new) — copied from global~/.claude/skills/so the canonical package directory is complete (25 skills total).
[0.3.1.37] - 2026-06-01¶
Fixed (consumer agent — distinct feature count)¶
- Feature counts no longer inflated by row duplicates (
agent/consumer_agent.py,agent/graph.py) — a feature's identity is the pair(FEATURE_NAME, ENTITY_NAME); the same name registered under two entities is two distinct features and a feature appearing in several datasets must still count once. New shared_distinct_feature_count(df, group_by=None)helper de-duplicates on the identity pair (tolerant ofENTITY_NAME/ENTITY_IDcolumn naming). Applied atget_data_domain()(was.size()over raw rows),skill_summary()domain-wide (total_features = len(feat_doc)), andskill_search()(wasnunique("FEATURE_NAME"), which also referenced a non-existentENTITY_IDcolumn for the per-entity breakdown — now usesENTITY_NAMEwithENTITY_IDfallback). The twogroupby("ENTITY_NAME").size().idxmax()dominant-entity picks ingraph.pyget the same dedup so they are robust to duplicate rows.
Added (vector store — Teradata Enterprise Vector Store backend, opt-in)¶
- Backend selector (
__init__.py,config.py) — newtdfs4ds.VECTOR_STORE_BACKENDconfig global ('chroma'default,'teradata'opt-in) plusTERADATA_VECTOR_STORE_PREFIX(base name for the three named stores:{prefix}_objects,{prefix}_sections,{prefix}_columns). Registered in_FILE_VARSso.env, JSON config, andTDFS4DS_*env vars all work. agent/vector_store_teradata.py(new) — Teradata backend mirror of the Chroma path. Reuses the existing backend-agnostic fetch + doc-builder helpers fromvector_index.py(_fetch_objects/_sections/_columns,_objects_to_docs, etc.).build_teradata_index(force_rebuild)callsTeradataVectorStore.from_documents(name=, documents=, embedding=)(rebuild path) and triesadd_documents(incremental path) with destroy + rebuild fallback.search_teradata_index(...)callssimilarity_search(question=, top_k=, return_type="json")and parses results into the same six-column DataFrame schema (SOURCE, OBJECT_NAME, SECTION_NAME, COLUMN_NAME, TEXT, SCORE); the parser absorbs lists ofDocuments, dicts,.similar_objectsobjects, and JSON strings.section_namesfilter is applied post-hoc.- Backend dispatch in
vector_index.py—build_vector_indexandsearch_vector_indexnow branch onVECTOR_STORE_BACKEND; callers (chatbot.launch_chatbot_with_index,consumer_agent._vector_search_context, publicagent.__init__exports) are unchanged. get_vector_store_embedding()(agent/embedding.py) — embedding factory for the active backend. Whentdfs4ds.TERADATA_AI_API_TYPEisNone, returns the existing LangChainEmbeddingsfromget_embeddings()so vLLM / TEI / OpenAI-compatible / Bedrock setups keep working unchanged. When set, returnsteradatagenai.TeradataAI(api_type=..., model_name=..., region=..., api_base=..., access_key=...);TeradataVectorStoreaccepts both object types at itsembedding=argument.- New TeradataAI config globals (
__init__.py,config.py) —TERADATA_AI_API_TYPE('aws'|'azure'|'gcp'|'nim'|'hugging_face'|'onnx'),TERADATA_AI_MODEL_NAME,TERADATA_AI_REGION(aws),TERADATA_AI_API_BASE(nim), andTERADATA_AI_ACCESS_KEY(registered as a credential —.env/ OS env var only; rejected from JSON config). - Dependencies (
setup.py) —langchain_teradatawas already present;teradatagenaiadded so the TeradataAI opt-in works out of the box (still lazy-imported so the package is optional at runtime).
Notes¶
- The Enterprise Vector Store API does not yet guarantee per-id upsert, so a non-
force_rebuildbuild attemptsadd_documentsand falls back to destroy + rebuild. - Live-system items to verify when first switching to the Teradata backend: exact
similarity_search(return_type="json")shape,Document.metadataround-trip, andadd_documentsavailability. The parser and builder absorb either form; the rebuild fallback is automatic.
[0.3.1.36] - 2026-05-21¶
Added (consumer agent — interactive charts from MCP tools)¶
agent/chart_render.py(new) — host-side rendering of MCP chart data. teradata-mcp-server'splot_*tools return Chart.js JSON ({"data": {"labels", "datasets"}, "metadata": {...}}), not MCP-UI / MCP Apps resources._chartjs_to_plotly_html(tool_name, spec)maps that JSON to a Plotly figure (plot_line_chart→Scatter,plot_pie_chart→Pie,plot_polar_chart→Barpolar,plot_radar_chart→Scatterpolar) and emits a self-contained HTML document with plotly.js inlined (include_plotlyjs=True) so it works air-gapped — no CDN.wrap_iframe()embeds it in asandbox="allow-scripts"iframe (noallow-same-origin→ isolated origin);charts_to_panel_html()stacks multiple charts.- Chart capture in the MCP skill (
agent/consumer_agent.py) —_extract_chart_from_tool_output()recovers Chart.js payloads from the LangChain ToolMessage.artifact(structured_content) thatlangchain-mcp-adapters0.2.2 already exposes._skill_mcp_tools_asynccollects charts duringon_tool_end(independent of the thread-local trace buffer — charts travel back via the return value) and returns them underskill_result["charts"]. - Chart rendering in the chatbot (
agent/chatbot.py) — new collapsible "Visualizations" panel (gr.HTML, same pattern as the context graph). Charts also render inline in the assistant's chat bubble (Chatbot(sanitize_html=False), each chart a sandboxed iframe). The panel resets per turn and on Clear.
Notes¶
- Only the Chart.js path is handled (generic
ui://MCP-UI / MCP Apps host rendering and thepostMessagehost↔iframe bridge are out of scope). Charts are self-contained interactive (Plotly native zoom/pan/hover); each inlined chart carries the full plotly.js bundle (~3.5 MB).
[0.3.1.35] - 2026-05-21¶
Fixed (round-2 agent quality)¶
- Feature count wrong on non-overview SEARCH turns (
agent/consumer_agent.py,synthesize_answer) — the LLM was computing its own count from context (e.g. "44 features" on entity-scoped turns) despiten_featuresbeing in the skill result. The synthesizer now quotes the exact value inline ("The total registered feature count is EXACTLY N") so the LLM has no room to invent a different number. - Dashboard query uses metric columns as identifiers (
agent/consumer_agent.py,synthesize_answer) — whenskill_compose_dashboardreturnsdashboard_intent=True, the synthesizer now builds a structured SQL template that placesentity_keysin the SELECT's identifier columns andfeature_namesas measure columns, with anORDER BY <ranking_metric> DESCclause. The LLM no longer picks arbitrary metric columns (e.g.provider_lifetime_avg_amount) for the provider/pharmacy slot. - Overview answers truncated to 4 sections (
agent/consumer_agent.py,synthesize_answer) — post-LLM hard truncation whenlen(answer) > 1500andis_overview=True. Splits on markdown headings and appends a "truncated" note. The existing soft prompt cap was ignored byministral-14b. $$\nYES\n$$LaTeX block rendered (agent/consumer_agent.py, EXPLAIN direct-answer path) —calculation_latexis now validated before wrapping in$$ ... $$. Strings that contain no LaTeX-ish characters (=,+,-,\,^,_,(,),*,/) are silently skipped instead of rendered as$$\nYES\n$$.- Group-pronoun freshness empty after SEARCH turns (
agent/graph.py,_capture_feature_group_from_search) — when SEARCHmatchescontain notype:'feature'entries (LLM emitted prose), the helper now falls back to scanningavailable_contextforFeature: <NAME>lines and intersects against_known_catalog_names()to populateresolved_feature_list. Fixes turn-14 pharmacy freshness after a SEARCH turn.
Added (documentation pipeline — formula-grounded documentation)¶
_extract_interpretation_hints(sql)(genai/documentation.py) — full-text regex scan of the entire SQL string (WHERE, GROUP BY, subqueries, CTEs included) forABS(,1.0 -,STDDEV,PERCENTILE_CONT,SIGN(, and aggregation functions. Returns a dict of boolean signals passed to the LLM as a constrained context section. Avoids the AST-only approach which misses formulas embedded in subqueries.- Formula-grounded prompt (
skills/qo-skill-explain/prompts/qo-document-sql.md) — upstream column docs are now "context, not authority." New FORMULA CONSISTENCY RULES section maps eachinterpretation_hintssignal to a mandatory prose constraint (is_absolute_value→ never use "signed";is_inverted→ explicitly state direction; etc.). - Post-validation gate with retry (
genai/documentation.py,document_sql_query_columns) — after LLM returns descriptions,_find_hint_violationschecks for prose that contradicts active hints (e.g. "signed" whenis_absolute_value=True). On violation: one retry with explicit correction note in context. On persistent violation after retry: description prefixed with[FORMULA CONSISTENCY WARNING: ...]and logged as error. migrate_documentation_columns_schema()(genai/documentation.py) — idempotentSHOW TABLE-based migration that addsMONOTONIC_DIRECTION,VALUE_DOMAIN,IS_ABSOLUTE_VALUE, andFORMULA_CONSISTENCY_WARNINGcolumns toFS_BUSINESS_DICTIONARY_COLUMNS. Same pattern asmigrate_documentation_explain_schema.document_view_incremental(database, view_name, ...)(genai/documentation.py) — public entry point to document any Teradata view and its upstream dependencies, even when the view is not registered in the process or dataset catalog. Mirrors the inner loop ofdocument_process_incrementalbut seeded from an arbitrarySELECT * FROM {db}.{view}. Exposed ontdfs4ds.genai.- Refresh notebook (
notebook dev/genai/14 - Refresh Feature Documentation.ipynb) — three-cell workflow: (1) schema migration, (2)document_feature_store_incremental(force_update=True)for all catalog objects, (3) optional loop over additional non-catalog views usingdocument_view_incremental. Includes a sharedvisitedset so multi-seed runs don't re-document shared dependencies.
[0.3.1.34] - 2026-05-21¶
Fixed¶
- MCP
astream_eventscrash on multi-feature dashboard questions (agent/consumer_agent.py,_skill_mcp_tools_async) —data["output"]for inner-chainon_chain_endevents is sometimes alist[BaseMessage], not a dict, sodata.get("output", {}).get("messages", [])raised'list' object has no attribute 'get'and killed the React loop. Output is now defensively unwrapped (dict, list, or other) and the top-level chain is gated viametadata.langgraph_nodeso intermediate tool messages no longer clobber the final answer. skill_compose_dashboardignores entity keys and partial matches (agent/consumer_agent.py) —_extract_feature_list_from_questionpreviously returned a case-sensitive feature list only, so questions like "top 50 highest-risk claims with risk band, top driver, provider and pharmacy" matched <2 features and fell through to a literal single-feature search. The helper now returns(features, entities)(case-insensitive, parsesENTITY_IDfor entity-key tokens), andskill_compose_dashboardhandles the single-feature path by delegating to_skill_dataset_singlewithdashboard_intent=Trueandentity_keysset.- SEARCH does not populate
resolved_feature_list(agent/graph.py,node_skill_search) — only SUMMARY captured the feature group, so follow-up group-pronoun freshness ("are these pharmacy features fresh?") had nothing to aggregate after a SEARCH turn. New_capture_feature_group_from_search()helper lifts feature names fromskill_searchmatches andskill_compose_dashboardresults intoresolved_feature_list, and resolvesresolved_entity_namewhen all matched features share one entity. - Hallucinated feature counts in overview answers (
agent/consumer_agent.py,skill_search+synthesize_answer) — the LLM was counting its own bullets (52 in one turn, 125 in another for the same catalog).skill_searchnow emits catalog-groundedn_featuresandn_features_by_entity, and the synthesizer prompt instructs the LLM to quote those numbers verbatim instead of computing its own. - MCP React agent hallucinates feature/dataset names (
agent/consumer_agent.py,_skill_mcp_tools_async) — the MCP system prompt previously listed dataset names only, leaving the React loop free to invent feature names (claim_amount_zscore_provider,ds_scoringobserved in earlier runs). The prompt now lists every validFEATURE_NAMEfrom the catalog and forbids invented variants. - Lineage / EXPLAIN speculate about source-table provenance (
agent/consumer_agent.py,skill_lineage+_skill_explain_multi) — when a source table has no business description, the LLM was inventing facts ("synthetic 20,000 records" vs "real healthcare claims"). Both system prompts now strictly forbid inventing facts about undocumented sources. - Overview SEARCH ignores
AGENT_ANSWER_STYLE='standard'(agent/consumer_agent.py,synthesize_answer) — turn-1 overview answers were 1500+ words. A hard 200-word / 4-bullet-group budget is now enforced when the question matches overview cues (overview,what's available,new here,vue d'ensemble, ...).
Added¶
- Source citations on FRESHNESS and LINEAGE answers (
agent/consumer_agent.py) —skill_freshness,skill_freshness_for_feature_list, andskill_lineagenow emit a structuredsourcesdict (process_ids,follow_up_method,seed_query,ddl_objects,edges_total, ...). New_render_sources_footer()insynthesize_answerappends a**Sources** — ...line that lets the user verify or escalate the claim. Suppressed underAGENT_ANSWER_STYLE='concise'to keep short answers short.
[0.3.1.33] - 2026-05-21¶
Added¶
AGENT_ANSWER_STYLEglobal (__init__.py) — controls verbosity of every consumer-agent response ('concise' | 'standard' | 'verbose'). Threaded into every_llm_callvia a new_style_prefix()helper inagent/consumer_agent.py.skill_freshness_for_feature_list(agent/consumer_agent.py) — new helper that aggregates freshness across a list of feature names.node_skill_freshness(agent/graph.py) now detects group pronouns ("these features", "the ones I just saw", "ces features") and, when a multi-feature list is remembered from a prior turn, routes the question to this aggregator instead of failing to resolve a single object.skill_compose_dashboard(agent/consumer_agent.py) — new SEARCH-side composition path. When the question looks like a dashboard request (mentionsdashboard/top N/SELECTand references multiple feature concepts), an LLM call extracts the implied feature names from the catalog, then re-uses multi-featureskill_datasetto find datasets exposing the whole set (or report a JOIN gap when none does). Wired innode_skill_search(agent/graph.py).- EXPLAIN formula completeness check (
agent/consumer_agent.py) — new_extract_formula_identifiers,_extract_ddl_tables, and_validate_source_columnshelpers. Afterskill_explaincalls the LLM, every identifier in the formula is cross-checked against the LLM'ssource_columnslist (missing ones appended withsource_table='UNKNOWN') and each claimedsource_tableis annotated withsource_confidence(highwhen it appears in the view's FROM/JOIN set,lowotherwise). _known_catalog_nameshelper (agent/consumer_agent.py) — returns the union of registered feature, dataset and process-view names. Used to drop unverified hits returned by the vector index and to filter LLM-generated search matches so fabricated feature names never reach the user. Dropped names are logged and returned asdropped_unverifiedon the skill result.detect_data_domain_for_object(..., prefer_domain=)parameter (agent/consumer_agent.py) — when an object name exists in multiple DATA_DOMAINs, returns the previously-resolved one if it is among the matches. This eliminates silent domain switching on ambiguous names.node_detect_domainnow passes the stickycurrent_resolved_domainas the preference.
Fixed¶
- DEFINITION silently fails for near-miss feature names (
agent/consumer_agent.py,skill_definition) — when the requested feature was not found in any catalog and the vector index returned nothing, the skill returned a generic "No documentation found" message. It now runs_fuzzy_match_featurefirst and returnsfeature_not_found_did_you_meanwith up to 3 candidates so the synthesiser surfaces the near-matches (handles cases likeprovider_z_score→provider_amount_zscore/pharmacy_z_score). - USAGE answers list only one dataset, no SELECT (
agent/consumer_agent.py,skill_usage) — extended to enumerate all datasets that expose the feature and to emit a paste-readySELECT <entity_keys>, <feature> FROM <db>.<dataset>for each. Entity-key columns are derived fromfeature_catalog.ENTITY_IDso the projection is join-ready. The newdatasetsfield on the skill result carries this information for downstream consumers.
[0.3.1.32] - 2026-05-21¶
Added¶
MCP_TIMEOUTconfig variable (__init__.py) — total seconds allowed for one MCP skill call covering connection, tool discovery, and ReAct loop. Default 120 s.- Live growing trace panel (
agent/chatbot.py) — the "Agent trace" accordion now updates on every step: each graph node appends a row to a live markdown table (# | Step | Detail) instead of remaining blank until the answer arrives. MCP tool calls appear as indented sub-rows (↳ MCP · tool_name | input). The final structured trace still replaces the live table when the answer arrives. - MCP tool-call side-channel (
agent/consumer_agent.py,agent/graph.py) — athreading.local()buffer (_mcp_trace_local) captureson_tool_start/on_tool_endevents from the MCP ReAct agent;consumer_agent_streamdrains the buffer after the MCP node and emits("mcp_tool", tool_name, tool_info)events for the live trace.
Fixed¶
- MCP calls hang indefinitely (
agent/consumer_agent.py) —client.get_tools()had no timeout;agent.ainvoke()had no end-to-end timeout. Both are now wrapped withasyncio.wait_for(30 s forget_tools,MCP_TIMEOUTfor the full ReAct loop). The non-Jupyterasyncio.runpath was also missing a timeout; both paths now go throughThreadPoolExecutor.result(timeout=MCP_TIMEOUT + 5). - MCP
ainvokereplaced withastream_events(agent/consumer_agent.py) — enables per-tool-call event capture for the live trace without breaking the existing answer extraction.
Changed¶
- Trace accordion open by default (
agent/chatbot.py) — changed fromopen=Falsetoopen=Trueso the live trace is immediately visible without a click. consumer_agent_streamstep events now 4-tuples (agent/graph.py) —("step", node_name, partial_state, step_num)replaces the old 3-tuple;step_numis a 1-based counter used by the live trace row formatter.
[0.3.1.31] - 2026-05-21¶
Fixed¶
- Redundant "Dataset availability" section on multi-feature questions (
agent/graph.py,node_synthesize) — when a question asked about multiple features and mentioned "dataset",node_synthesizeran a secondaryskill_datasetlookup and appended a full "Dataset availability" block even though the primary multi-object answer already contained complete per-feature dataset membership. The secondary block was suppressed whenskill_result["_multi_object"]is set, eliminating the duplicate narrative.
[0.3.1.30] - 2026-05-21¶
Fixed¶
- Multi-feature questions crash (
agent/graph.py,_build_context_update) — when a skill result contained acontext_graphwithshared_datasetsstored as a list (the format returned by_build_multi_object_context), the code called.items()on it expecting a dict, raising'list' object has no attribute 'items'. Now handles both list-of-names and dict forms gracefully.
[0.3.1.29] - 2026-05-20¶
Fixed¶
- Graph canvas height (
agent/graph.py,_build_context_graph_pyvis) —Network(height=750)was passing an integer; pyvis embeds the value verbatim in its HTML template producing invalid CSSheight: 750;(no unit). Changed toNetwork(height="750px")so the canvas renders at the intended height. - Duplicate nodes in context graph (
agent/graph.py,_build_context_graph_pyvis) — inconsistent node-ID schemes across code paths caused the same logical object to appear as two disconnected nodes (e.g. a dataset shown once connected and once isolated). Root causes: - Feature:
feature_{name}(most paths) vscontext_feature_{name}(multi-object context_graph path) - Source view:
view_{name}(triplet path) vscontext_view_{name}(context_graph path) - Entity: bare
"entity"(early state path) vsentity_{name}(accumulated graph paths) - Dataset: raw UUID string (early state path) vs
dataset_{name}(accumulated + context_graph paths) Fixed by normalizing all node IDs to a single uppercased name-based scheme throughout:feature_{NAME_UPPER},source_view_{NAME_UPPER},entity_{NAME_UPPER},dataset_{NAME_UPPER}. Process nodes keep their UUID-based IDs (already unique). All edge references updated accordingly. - Post-processing consolidation (
agent/graph.py,_build_context_graph_pyvis) — added a safety-net pass afternodes_dictis built that merges any remaining nodes sharing the same(type, label)pair. Duplicate edges and self-loops created by the merge are removed. This prevents visual duplicates even if future code paths introduce new ID inconsistencies.
[0.3.1.28] - 2026-05-20¶
Added¶
accumulated_context_graphstate field (agent/graph.py) — new persistentAgentStatefield storing a structural relationship map that grows across conversation turns: features → {entity, domain, process_id, view_name, dataset_names}, processes → {view_name, entity_name, feature_names}, datasets → {dataset_id, database, feature_names, entity_names, process_ids}, entities → {domain, feature_names}, source_views → {referenced_by_process_ids}. Persisted via MemorySaver so it survives turn boundaries._merge_accumulated_contexthelper (agent/graph.py) — non-destructive deep merge for updating the accumulated structural map without overwriting existing entries._build_context_updatehelper (agent/graph.py) — extracts structural relationship updates from skill results (single-feature triplet path and multi-featurecontext_graph.featurespath) into the accumulated map format.- Context-first navigation in
skill_definition(agent/consumer_agent.py) — before running the full feature catalog scan, checks if the feature is already mapped inaccumulated_context_graph. If found, navigates directly to the known view via targeted BD_COLUMNS/BD_SECTIONS queries, skipping the catalog scan entirely. Trace showsACCUMULATED_CONTEXT_MAPas a docs source. - Context-first navigation in
skill_freshness(agent/consumer_agent.py) — checksaccumulated_context_graph.features[X].process_idbefore queryingfeature_catalog → process_catalog. When process_id is already known, callsfollow_up_report()directly. - Context-first navigation in
_build_multi_object_context(agent/consumer_agent.py) — per-feature check against accumulated map; skips per-feature catalog scan and view/dataset lookups for already-mapped features. - Dataset membership lookup in
skill_definition(agent/consumer_agent.py) — after resolving a single feature's triplet, looks up which datasets include that feature (viaget_dataset_features()+dataset_catalog()), unless already present in the accumulated map. Returnsresolved_dataset_idanddataset_namefor graph rendering. - Process nodes in context graph (
agent/graph.py,_build_context_graph_pyvis) — Process nodes rendered fromaccumulated_context_graph.processeswith light sea green color (#20B2AA) and hexagon shape. Edges: Entity → Process and Feature → Process (replaces raw "Feature → View" for process views). Legend updated with Process entry.
Changed¶
- Full state for graph visualization (
agent/graph.py,consumer_agent_stream) — graph visualization now readsgraph.get_state(config).values(full MemorySaver-persisted state) instead of current-turn deltas, so nodes from all prior turns remain visible in the context graph. - Pyvis tooltip plain text (
agent/graph.py,_build_context_graph_pyvis) — replaced HTML tags (<b>,<br>) intitleattributes with plain text (\nseparators) since vis.js renderstitleas plain text, not HTML. Previously showed raw HTML markup on hover. - Graph panel height (
agent/graph.py) — increasedNetwork(height=...)and iframe height from 500px to 750px for better visibility of complex graphs. - Dataset nodes from multi-feature context graph (
agent/graph.py,_build_context_graph_pyvis) — now readscg_feat["datasets"]when processingcontext_graph.features, adding Dataset nodes (purple triangle) with edges to their features. - Accumulated context written after each skill (
agent/graph.py) —node_skill_definition,node_skill_freshness, andnode_skill_summaryall call_build_context_update+_merge_accumulated_contextand persist the result inAgentStateafter each successful skill execution. - "Source view" legend label (
agent/graph.py) — renamed "Computed by view" → "Source view" in the graph legend to distinguish raw/lineage views from process views and dataset views.
Technical Details¶
accumulated_context_graphkeys are uppercased for case-insensitive lookup (e.g.,features["FEAT_NAME"]); canonical casing is stored inside the value dict.- View name is stored directly in feature entries (denormalized from processes) so skills can navigate
features[X]["view_name"]in one step. - Process view names label Process nodes (readable) with process_id in the tooltip; no separate "Process → View" edge since the view_name is the label.
- Dataset views and process views are kept in separate sections; raw/lineage views (from LINEAGE/EXPLAIN traversal) go in
source_views.
[0.3.1.25] - 2026-05-20¶
Changed¶
- Context graph visualization (
agent/graph.py,agent/chatbot.py) — replaced static Plotly scatter plot with dynamic Pyvis network graph. Nodes are now draggable with physics-based layout, smooth edge arrows, and physics stabilization (150 iterations, Barnes-Hut gravity). Rich HTML hover tooltips on all nodes (type + name) and edges (relationship description: "entity has feature", "dataset exposes feature", etc.). HTML legend table below the graph shows all 6 node types with color swatches. Graph wrapped in<iframe srcdoc=...>for sandboxed rendering. - Dataset support in DEFINITION, LINEAGE, and FRESHNESS skills (
agent/consumer_agent.py) — extended all three skills to handle registered dataset names as query objects: - DEFINITION: Quaternary path now calls
_resolve_dataset()and_get_dataset_with_features()after BD_OBJECTS/BD_SECTIONS/BD_COLUMNS checks succeed, so dataset documentation is returned alongside business dictionary entries. - LINEAGE: New dataset branch at start of view-resolution section resolves the object as a
dataset and uses
dataset_nameas the seed view for dependency graph construction. Returnsresolved_dataset_id,resolved_dataset_featuresin result so graph builder can render dataset node with connected features. - FRESHNESS: New dedicated dataset branch (before feature catalog lookup) resolves the object,
collects unique process_ids from dataset features, calls
follow_up_report()for each, and aggregates per-entity freshness (entity name → feature count → last run date → status). Returns dataset-level reliability assessment. - AgentState multi-turn context (
agent/graph.py) — addedresolved_dataset_name: Optional[str]to persist the human-readable dataset name alongsideresolved_dataset_id. Extended_skill_with_triplet()to propagate dataset context (resolved_dataset_id,resolved_dataset_features,resolved_dataset_name) from skill results to state, so all skills (not just SUMMARY) persist dataset context for follow-up questions. Updatednode_skill_summaryto also setresolved_dataset_name. - Dataset node rendering (
agent/graph.py,_build_context_graph_pyvis) — dataset nodes now useresolved_dataset_nameas the label (human-readable) instead of 8-char UUID prefix. Feature nodes referenced byresolved_dataset_featuresare now created innodes_dictwith proper edges. Entity nodes from dataset features are also added to the graph when available, showing the full entity→feature hierarchy within the dataset context.
Technical Details¶
- No new temporal SQL introduced; all dataset and process catalog access reuses existing API functions
(
_resolve_dataset(),_get_dataset_with_features(),process_catalog(),follow_up_report()) whose CURRENT VALIDTIME semantics are baked into view DDLs. - Case sensitivity handled consistently: dataset name matching uses
.upper()on both sides; seed SQL for lineage uses original catalog case (NOT CASESPECIFIC behavior). - Pyvis physics configuration:
stabilization.iterations=150,barnesHut.gravitationalConstant=-8000,springLength=120, smooth dynamic edges, directional arrows.
[0.3.1.23] - 2026-05-19¶
Added¶
- Mistral AI embedding provider (
agent/embedding.py) — added support formistralaiprovider vialangchain_mistralai.MistralAIEmbeddings. SetEMBEDDING_MODEL_PROVIDER = "mistralai"to enable. Model defaults to"mistral-embed"but is configurable viaEMBEDDING_MODEL_MODEL.
Changed¶
- Vector index upsert semantics (
agent/vector_index.py) — replaced_add_documents_batchedwith_upsert_documents_batchedto enable update-if-exists behaviour. Re-runningbuild_vector_index(force_rebuild=False)now replaces stale embeddings when documentation is updated, instead of silently skipping rows whose IDs already exist. Upsert is automatic and transparent to the user. - Configurable embedding batch delay (
agent/vector_index.py,__init__.py,config.py) — addedEMBEDDING_BATCH_DELAYglobal (default 0.0 seconds). Set to a positive value (e.g. 0.5) to throttle embedding batch submissions and prevent vLLM/TEI server overload / panic mode. Delay is applied between batches, never after the final batch. Override viaTDFS4DS_EMBEDDING_BATCH_DELAYenv var or.envfile.
[0.3.1.22] - 2026-05-19¶
Fixed¶
- Embedding model token limit handling (
agent/vector_index.py,config.py,__init__.py) — fix chatbot startup error "Input validation error: inputs must have less than 512 tokens" that occurs when business dictionary sections exceed the TEI server's token cap. Added configurable globalEMBEDDING_MODEL_MAX_TOKENS(default 450 = 12% safety margin below 512 forintfloat/multilingual-e5-large). New_truncate_text()helper in vector_index.py truncates all text (objects, sections, columns) tomax_tokens × 4 charsbefore embedding. Users can override viaTDFS4DS_EMBEDDING_MODEL_MAX_TOKENSenv var or.envfile for larger-context models. Rebuild the vector index withforce_rebuild=Trueafter changing the limit.
[0.3.1.21] - 2026-05-19¶
Changed¶
- Comprehensive consumer agent documentation (
docs_src/ai/consumer-agent.md) — expanded the AI capabilities guide with full LangGraph workflow diagram (10 intents + routing), graph singleton pattern explanation, AgentState as a named-entity knowledge graph (all 9 persistent fields documented), MCP integration architecture with governance guards, and hybrid retrieval strategy (structured SQL + vector fallback per skill). Documentation now covers business dictionary 3-level hierarchy (OBJECTS, SECTIONS, COLUMNS), Chroma collections, and per-skill retrieval patterns. - LaTeX rendering fix (
agent/consumer_agent.py) — strip LLM-added$delimiters before wrapping in fresh delimiters to prevent double-delimited$$...$$ $$...$$that KaTeX rejects as red text. LLM now correctly renders EXPLAIN formulas despite sometimes ignoring the "no delimiters" instruction. - Chatbot UI improvements (
agent/chatbot.py): - Added
show_copy_button=Trueto enable copy-to-clipboard on all messages. - Fixed session context persistence: thread_id now rotates to a new UUID on "Clear conversation" so LangGraph MemorySaver checkpoint resets and cross-turn state (domain, entity, feature group) is forgotten as expected.
- Added collapsible "Context knowledge graph" accordion showing interactive Plotly network visualization of accumulated context (domain → entity → features → views → datasets).
- Context graph visualization (
agent/graph.py) — new_build_context_graph_plotly(state)builds a NetworkX directed graph from all context fields and renders as color-coded Plotly network (blue diamond for domain, green square for entity, orange circles for features, grey squares for views, purple triangles for datasets, red nodes for column sources). Graph updates after each turn to reflect new resolutions and multi-object DEFINITION expansions. ReturnsNonewhen state is empty (no context yet).
[0.3.1.20] - 2026-05-17¶
Changed¶
- Gradio trace panel now renders the multi-object context graph (
agent/graph.py,agent/consumer_agent.py) —skill_definition_multi()now includes acontext_graphkey in its result with per-feature resolution status (found/not found, entity, view, has docs, dataset membership), shared view names, and shared dataset names. The trace panel renders a "Context Graph (multi-object)" section showing exactly which nodes were resolved and which docs were fetched, making it easy to diagnose missing documentation or wrong entity disambiguation.
[0.3.1.19] - 2026-05-17¶
Changed¶
- Multi-object DEFINITION now uses a graph-based shared context (
agent/consumer_agent.py,agent/graph.py) — new_build_multi_object_context()helper resolves all requested features in a single catalog pass, then fetches shared documentation (process view sections, dataset overviews) once per unique node rather than once per feature. Newskill_definition_multi()function assembles a unified context string and makes a single LLM call that sees entity, process, dataset, and per-feature descriptions simultaneously, enabling genuine cross-referencing. Features without business descriptions are clearly flagged; if nothing is documented the LLM is skipped entirely and a structured "not documented yet" response is returned. The post-hoc consolidation pass insynthesize_answeris removed — it is no longer needed.
[0.3.1.18] - 2026-05-17¶
Changed¶
skill_definitionno longer speculates when no documentation exists (agent/consumer_agent.py) — whencontext_partscontains only structural metadata (entity name, process view name, catalog boilerplate) with no actual business descriptions, the LLM call is skipped and a clear "registered but not documented yet" message is returned instead. This eliminates hallucinated feature explanations generated from feature names and sibling column names alone.documentation_not_availableerror replaced with clean message — the raw error key that surfaced in the chatbot UI is now a user-readable "No documentation found for X." explanation, integrating cleanly into multi-object section output.- Multi-object DEFINITION consolidation pass (
agent/consumer_agent.py,agent/graph.py) — when multiple features are queried in a single DEFINITION request,node_skill_definitionflags the result with_multi_object=Trueandsynthesize_answerruns a single consolidation LLM call to merge the independent per-feature sections into one coherent, cross-referenced answer. Sections for undocumented features clearly state they are not documented; the LLM is instructed not to infer their meaning from other sections. Falls back to the concatenated sections if the consolidation LLM call fails.
[0.3.1.17] - 2026-05-17¶
Added¶
DATA_QUERYintent for MCP data retrieval (agent/graph.py,agent/consumer_agent.py,agent/chatbot.py,skills/ca-data-query/SKILL.md) — the consumer agent can now route sample and aggregation requests (e.g. "show me 20 rows from DS_FRAUD", "how many claims per region?") to the MCP server whenmcp_enabled=True. The new intent replaces the vagueMCP_TOOLSlabel with a specific, actionable description that the classifier reliably distinguishes from feature store metadata intents (DEFINITION, FRESHNESS, EXPLAIN). Feature store skills always take priority; MCP is the fallback for data retrieval only.- Automatic MCP fallback routing —
_route_intentnow falls back toDATA_QUERY(instead of the first registered skill) whenmcp_enabled=Trueand the classifier returns an unrecognised intent. - Process view guard preserved —
_get_forbidden_objects()already blocks direct queries against process views (FS_V_*) and feature storage tables; only registered datasets are queryable via MCP. - Step indicator — Gradio chatbot shows "Querying data — DS_X..." during MCP execution.
[0.3.1.16] - 2026-05-17¶
Fixed¶
- Multi-object DEFINITION returns
documentation_not_available(agent/graph.py) — when the user named multiple features, datasets, or a mix (e.g. "what is combined_score and risk_band?"), the classifier produced a comma-separatedobject_namethat was passed as a single lookup key._get_feature_catalog_with_docsdid an exact match and found nothing, so all fallback paths failed.node_skill_definitionnow splits on commas, callsskill_definitiononce per name, and combines the individual explanations into one response with per-object section headers. Works for features, datasets, and mixed questions.
[0.3.1.15] - 2026-05-17¶
Added¶
- Real-time step indicator in Gradio chatbot (
agent/chatbot.py,agent/graph.py) — the chatbot now shows a live progress label as each graph node completes, instead of a frozen interface. Labels include the argument being processed (e.g.⏳ Looking up definition — **combined_score**...,⏳ Detecting data domain → **MY_DOMAIN**...). consumer_agent_stream()(agent/graph.py) — new generator variant ofconsumer_agentthat usesgraph.stream(stream_mode="updates")to yield one("step", node_name, partial_state)event per completed node, followed by a("done", answer, trace_md)event. MemorySaver multi-turn memory is fully preserved.- Immediate user-message display — the first yield in
_respond()pushes the user question to the chatbot and clears the input box before the agent starts, so the question no longer disappears on Enter.
[0.3.1.14] - 2026-05-16¶
Added¶
- Entity recognition node (
agent/graph.py) — newnode_recognize_entityinserted betweendetect_domainand skill dispatch. For each named object (comma-separated), queries the feature catalog, dataset catalog, and process catalog to identify the object type (feature/dataset/process/unknown). Stores results inrecognized_entitiesstate field. - Secondary DATASET intent — when a DEFINITION question contains dataset-related keywords
(e.g. "same dataset?") and all named objects are features,
node_recognize_entitysetssecondary_intent = "DATASET".node_synthesizethen also runsskill_datasetand appends a dataset-availability section after the definition answer. _recognize_object_type(name)helper inconsumer_agent.py— lightweight catalog lookup returning{type, entity_name, view_name, process_id}.
Fixed¶
- Documentation lookup gap in
skill_definition— when a feature description is stored inFS_BUSINESS_DICTIONARY_COLUMNSagainst a dataset view (not the process view), the view-scoped lookup in path 1 returned nothing. Path 1 now also runs a broad column-name-only search (COLUMN_NAME = feature_name, any TABLE_NAME) so docs are found regardless of which table name was used when they were written. - Catalog-only fallback — when a feature exists in the catalog but has absolutely no
documentation (all BD paths return nothing, vector search returns nothing), the agent now returns
what the catalog knows (entity, process view) with a note that docs are pending, instead of the
generic
documentation_not_availableerror that triggered the "reach out to data team" response. - No-doc error message —
synthesize_answerdocumentation_not_availablepath now produces a factual "not found in feature store, verify spelling" message instead of the unhelpful "reach out to your data team" suggestion. This path only fires when the object is truly absent from every catalog. - MCP
StructuredToolmonkey-patch —_make_guarded_toolsnow creates properStructuredToolwrappers instead of patchinginvoke/ainvoke(which raised"has no field 'invoke'"in Pydantic v2). - MCP context-manager —
_skill_mcp_tools_asyncupdated to the non-context-manager API (client = MultiServerMCPClient(...); tools = await client.get_tools()) required bylangchain-mcp-adapters >= 0.1.0.
Updated skills¶
tdfs4ds/skills/ca-definition/SKILL.md— documents the new resolution chain (step 0 entity recognition, step 1b broad BD_COLUMNS fallback, step 5 catalog-only fallback,from_catalog_onlyreturn key). Adds multi-feature + dataset classifier example.tdfs4ds/skills/ca-dataset/SKILL.md— notes secondary-intent triggering from DEFINITION.
[0.3.1.9] - 2026-05-16¶
Changed¶
- MCP transport is now configurable (
agent/consumer_agent.py,__init__.py,config.py) — The hardcoded"transport": "sse"has been replaced by atdfs4ds.MCP_SERVER_TRANSPORTglobal (default'streamable_http', the modern MCP standard) settable viaTDFS4DS_MCP_SERVER_TRANSPORTin.envor OS env. Use'sse'for legacy SSE servers.
[0.3.1.8] - 2026-05-16¶
Changed¶
- Classifier examples moved to SKILL.md (
agent/consumer_agent.py,tdfs4ds/skills/ca-*/SKILL.md,tdfs4ds/cli.py) — The hardcoded_INTENT_CLASSIFIER_EXAMPLESconstant has been removed from Python. Eachca-*/SKILL.mdfile now contains a## Classifier Examplessection with its own few-shot examples._read_skill_classifier_examples()incli.pyparses these sections;skill_catalog()exposes them under the"examples"key;_build_classifier_examples()inconsumer_agent.pyassembles the classifier prompt dynamically. Plugin skills installed viaTDFS4DS_SKILLS_FOLDERare automatically included if they have a## Classifier Examplessection.
[0.3.1.7] - 2026-05-16¶
Changed¶
- Multi-feature DATASET lookup (
agent/consumer_agent.py,agent/graph.py) — When the user asks "is there a dataset containing feature1 and feature2?", the agent now returns three layers of information: which datasets contain feature1, which contain feature2, and which datasets contain both (intersection). The classifier recognises comma-separated feature names inobject_namevia two new few-shot examples.skill_datasetdelegates to a single-feature path for one name and a new multi-feature path otherwise; the dataset creation flow (Step 1–3) is only triggered for single-feature no-match results.
[0.3.1.6] - 2026-05-16¶
Added¶
- MCP server integration for the consumer agent (
agent/consumer_agent.py,agent/graph.py,agent/chatbot.py) — The consumer agent can now delegate questions to an external MCP (Model Context Protocol) server when no feature store skill applies. - Configure the MCP endpoint via
TDFS4DS_MCP_SERVER_URLin.envor as an OS env var (e.g.TDFS4DS_MCP_SERVER_URL=http://localhost:8000/sse); the variable is auto-loaded intotdfs4ds.MCP_SERVER_URLon import. - Toggle at runtime via the new
mcp_enabledparameter onconsumer_agent()and on the Gradio chatbot's "Enable MCP Tools" checkbox (hidden when no URL is configured). - Safety guardrails enforced at both the tool and LLM-prompt level:
- Forbidden objects: SELECT statements against feature store process views or feature
storage tables are refused outright;
_get_forbidden_objects()fetches the live lists from the process and feature catalogs at call time. - Allowed data access: the sub-agent's system prompt directs it to only query datasets registered in the dataset catalog when feature data is needed.
- Row cap: all SELECT queries are rewritten to
SELECT TOP 20by_rewrite_select_top20(); a ⚠ notice is appended to the answer when the 20-row limit is reached.
- Forbidden objects: SELECT statements against feature store process views or feature
storage tables are refused outright;
- Uses
langchain-mcp-adapters(MultiServerMCPClient+ SSE transport) as a lazy import insideskill_mcp_tools(), so the dependency is optional. - Jupyter-compatible async execution: probes
asyncio.get_running_loop()and falls back to aThreadPoolExecutorwhen a loop is already running.
[0.3.1.3] - 2026-05-15¶
Changed¶
fs-uploadskill (tdfs4ds/skills/fs-upload/SKILL.md) — added mandatoryLOCKING ROW FOR ACCESSto the Pattern A SQL template, thepartitioning=comment in theupload_featurescall, and two new rules: access-lock mandate and hyper-segmented ML partition column guidance.fs-datasetskill (tdfs4ds/skills/fs-dataset/SKILL.md) — added rule explaining that a partition column must be registered as a feature viaupload_featuresbefore it can appear in a dataset (hyper-segmented ML pattern).
[0.3.1.2] - 2026-05-15¶
Fixed¶
generate_partitioning_clausecrashes on unparenthesisedPARTITION BY(utils/info.py) —extract_partition_contentuses a regex that requires outer parentheses (PARTITION BY (...)). When the user passes a bare string such as'PARTITION BY RANGE_N(region BETWEEN 1 AND 10 EACH 1, NO RANGE OR UNKNOWN)', the regex returnsNoneand the subsequentlen(None)raisedTypeError. Fixed by detecting theNoneresult and falling back to extracting everything afterPARTITION BYas the content, so FEATURE_ID partitioning is correctly prepended.
[0.3.0.7] - 2026-05-15¶
Added¶
drop_datasetmodule-level function (dataset/dataset_catalog.py) — exposeDatasetCatalog.drop_datasetas a standalone importable function so callers can writefrom tdfs4ds.dataset.dataset_catalog import drop_datasetwithout instantiating the class directly. Acceptsdataset_idand an optionalschema_name(defaults totdfs4ds.SCHEMA).
[0.3.0.6] - 2026-05-15¶
Fixed¶
- No feature columns documented in dataset documentation (
genai/documentation.py) —_extract_feature_process_map_from_ddlcaptured the full qualified column alias produced bybuild_dataset(e.g.provider_velocity_7d__demo_user___vw_feat_claims_) as the feature-name key._propagate_docs_from_processesthen tried to match that full alias against the short names returned byretrieve_documentation(e.g.provider_velocity_7d), which always failed, so every feature ended up inmissing_featuresand the dataset documentation showed "No feature columns documented." Fixed by stripping the__{database}___{view}_qualifier in_extract_feature_process_map_from_ddl(split on first__) so the map keys are always short business names. Duplicate short names across multiple processes are deduplicated by keeping the first encountered process (any documented process provides the same description).
[0.3.0.5] - 2026-05-15¶
Fixed¶
- Entity columns silently dropped for intermediate views (
genai/documentation.py) —_document_intermediate_viewhardcodedentity_columns=[]when callingdocument_sql_query_columns, so intermediate views (e.g.vw_temp_aggregate) were always documented with 0 entity columns regardless of the actual entity definition. Fixed by adding anentity_columnsparameter to_document_intermediate_viewand threading it through from both call sites:document_process_incrementalpassesprocess_info.get('ENTITY_COLUMNS', [])anddocument_dataset_incrementalpasses its localentity_columns. Matching is case-insensitive (uppercased set lookup) while preserving the column casing from the view's own column list.
[0.3.0.3] - 2026-05-15¶
Fixed¶
- Missing
skills/*/prompts/*.mdin installed package (setup.py) — thepackage_dataglob only includedskills/*/SKILL.md(top-level Claude Code skill descriptions). Nested LLM prompt files underskills/*/prompts/*.mdwere never packaged, so a non-editablepip install .produced asite-packages/tdfs4ds/skills/tree with noprompts/subdirectory. Everyskill_prompt("qo-summary"),skill_prompt("qo-explain"), etc. raisedFileNotFoundError: No bundled skill named 'qo-summary'. Fixed by adding'skills/*/prompts/*.md'topackage_data.
[0.3.0.2] - 2026-05-15¶
Fixed¶
_generate_dataset_descriptionAIMessage bug (genai/documentation.py) — the vLLM and fallback chains piped the LLM output directly into_robust_json_parser, which expects a plainstr. LangChain passes anAIMessagethrough the|operator, causing aValueError: Expected string response, got AIMessage. Fixed by wrapping withRunnableLambda(_parse)that extracts.contentfirst — the same pattern already used inbuild_sql_documentation_chainandbuild_explain_documentation_chain.
[0.3.0.1] - 2026-05-15¶
Changed¶
BUILD_DATASETentity clarification fallback (graph.py,consumer_agent.py) — when the feature catalog returns no features for the active domain, the agent no longer dead-ends immediately. Instead it enters a newawaiting_entity_clarificationpending state and asks the user to name the entity or subject area (e.g. "Customer", "Transaction"). The user's answer is used to retry_get_feature_catalog_with_docs(entity_filter=…)and, if features are found, the flow continues normally to feature proposal. Only when the retry also returns nothing does the agent reportno_features_foundwith a precise "no features matching X" message. The hard-stop message now includes the entity hint for better diagnostics._DATASET_PENDING_ACTIONS(graph.py) gains"awaiting_entity_clarification"so the LLM classifier short-circuit correctly routes the clarification reply back tonode_skill_build_dataset.ca-build-dataset/SKILL.mdupdated to document the newawaiting_entity_clarificationpending state and the two-attempt strategy.
[0.3.0.0] - 2026-05-15¶
Added¶
BUILD_DATASETconsumer-agent intent — a new 9th intent that lets a business user ask the agent to autonomously create a feature store dataset from a plain-language use-case description (e.g. "build me a dataset for churn analysis"). No feature name is required to start the flow.tdfs4ds/skills/ca-build-dataset/SKILL.md— skill file for the new intent, auto-discovered byconsumer_agent_skill_catalog()(uses theca-prefix convention). RegistersBUILD_DATASETinto the dynamic intent classifier and graph without any hardwired lists.resolve_features_for_dataset(entity_name, feature_list)(consumer_agent.py) — resolves a list of{name, description}feature dicts to theselected_featuresdict format required bybuild_dataset(). Callsget_feature_versions()and splits results into a clean{feature_name: uuid}dict (single-version features, ready forbuild_dataset()) and a conflicts list ([{feature_name, versions: [{process_id, process_view_name}]}]) for features with multiple registered process IDs.skill_build_dataset(entity_name, selected_features, dataset_name, ...)(consumer_agent.py) — end-to-end dataset creation skill: callsbuild_dataset()then, when an LLM provider is configured, auto-callsdocument_dataset_incremental(). Returnsstatus=dataset_createdwith adoc_statusfield (success/failed/skipped), orstatus=creation_failedon error. Acceptsreturn_query=Truefor dry-run / unit-test use without a live connection.node_skill_build_dataset(state)(graph.py) — LangGraph node implementing a 4-step autonomous creation flow:- Step 0 (no pending): resolves entity from the catalog (uses the feature-richest entity as
a heuristic when no feature name is given), calls
skill_propose_features()immediately to propose the top-20 relevant features for the use case. - Step 1 (
awaiting_feature_confirmation): user replies "ok" or "all"; callsresolve_features_for_dataset()to detect version conflicts. - Step 2 (
awaiting_version_selection): if conflicts exist, presents options (numbered) and waits for the user to pick a version per feature; parses replies by number or view-name substring. - Step 3 (
awaiting_dataset_name): validates the proposed name withskill_check_dataset_name(); on success immediately callsskill_build_dataset()and creates the Teradata view + docs.
Changed¶
IntentResultLiteral extended to include"BUILD_DATASET"(hardwired fallback model,consumer_agent.py).- Intent classifier examples gain three
BUILD_DATASETexamples (English and French) to disambiguate from the existingDATASETintent. _DATASET_PENDING_ACTIONS(graph.py) gains"awaiting_version_selection"so the LLM classifier short-circuit also covers the version-conflict resolution turn._node_classify_implshort-circuit (graph.py) now preserves the active intent instead of forcing"DATASET"—BUILD_DATASETflows remain correctly routed to their own node across all pending turns.synthesize_answer()(consumer_agent.py) gains a fullBUILD_DATASETbranch covering all status codes:features_proposed,features_confirmed,version_conflict,name_available,name_taken,dataset_created,creation_failed,no_features_found._INTENT_NODE_MAPand_SKILL_LABEL(graph.py) both include"BUILD_DATASET".
[0.2.9.9] - 2026-05-13¶
Added¶
- 11
qo-*prompt files reorganized into nestedprompts/subfolders under their owningqo-skill-*directories (e.g.qo-skill-explain/prompts/qo-explain.mdinstead of standaloneqo-explain/SKILL.md). Reduces skill namespace clutter while keeping related prompts together.
Changed¶
skill_path()incli.pynow searches nested prompt paths: When a flat SKILL.md is not found, it searches<skill>/prompts/<name>.md, allowingskill_prompt('qo-explain')to resolve transparently from the new nested location.install_skills()incli.pycopiesprompts/subfolders: When installing skills withtdfs4ds-install-skills(), theprompts/subfolder (if present) is copied alongsideSKILL.mdso relative links in skill bodies resolve correctly.qo-skill-*/SKILL.mdbodies rewritten with## Executionsections: Each now documents its full execution pipeline, references prompt files with relative[prompt-name](prompts/prompt-name.md)links, and explains inputs/outputs, matching the discoverable-skill pattern fromca-*andfs-*skills.- Argument-hint bracket format fixed on
qo-skill-*SKILL.md ([...]instead of<...>for consistency with other skills). - All
fs-*SKILL.md files now havename:frontmatter field with human-readableFeature Store: <Action>naming (e.g.name: Feature Store: Upload), consistent withConsumer Agent: *andQuery Optimizer: *conventions. - Query optimizer report naming changed to timestamp-first (
tdfs4ds_optim_{timestamp}_{view}_{process_id}.mdinstead oftdfs4ds_optim_{view}_{process_id}_{timestamp}.md). Reports now sort naturally by date in file listings.
[0.2.9.8] - 2026-05-13¶
Added¶
- 8 consumer-agent SKILL.md files (
tdfs4ds/skills/ca-*/SKILL.md): Each of the 8 consumer-agent intents (SEARCH, DEFINITION, USAGE, FRESHNESS, SUMMARY, LINEAGE, EXPLAIN, DATASET) is now a first-class Claude Code skill intdfs4ds/skills/with compliant frontmatter (name,intent,description,argument-hint,allowed-tools: Read, Grep, Glob). Skills are identified by theca-name prefix convention. They serve dual purpose: Claude Code users can invoke them as reference skills, andbuild_graph()reads them at runtime to configure the consumer agent dynamically. Bundled viapackage_data. consumer_agent_skill_catalog()(cli.py/ top-level API): Returns metadata for allca-*skills (filtered by name prefix), keyed by skill name. Used bybuild_graph()to discover available intents at runtime without any hardwired lists.reset_consumer_agent()(tdfs4ds/agent/graph.py): Clears the compiled-graph singleton cache so the nextconsumer_agent()call picks up any newly added or removedca-*SKILL.md files._build_intent_model(intents)(tdfs4ds/agent/consumer_agent.py): Helper that builds a dynamic Pydantic model for structured intent classification output usingUnion[Literal["A"], Literal["B"], ...](equivalent toLiteral["A", "B", ...]per PEP 586) andpydantic.create_model.
Changed¶
consumer_agent()gainsskills=parameter (tdfs4ds/agent/graph.py): Pass a list of intent names (e.g.['SEARCH', 'SUMMARY']) to restrict the agent to a subset of skills. Different subsets use separate compiled-graph instances (and separateMemorySaverstate), sothread_idnamespacing is preserved per skill set.build_graph()is now data-driven (tdfs4ds/agent/graph.py): Graph construction, routing, and the intent-classifier system prompt are all built dynamically fromconsumer_agent_skill_catalog()instead of being hardwired. Adding or removing aca-*SKILL.md file (and callingreset_consumer_agent()) changes the available intents without any Python edits.intent_classifier()accepts dynamic model and descriptions (tdfs4ds/agent/consumer_agent.py): New optional parametersintent_modelandintent_descriptionsallow the graph to inject a dynamically constructed Pydantic model and per-intent descriptions. Falls back to the hardwiredIntentResultand static descriptions when called without arguments (backward compatibility).install_skills()installs all 19 skills (cli.py):ca-*skills are valid Claude Code skills and are now installed alongsidefs-*skills. The total bundled skill count increases from 11 to 19.fs-agent/SKILL.mdupdated (tdfs4ds/skills/and.claude/skills/): The hardwired intents table is replaced with a reference to theca-*SKILL.md files and documentation for the newskills=parameter andconsumer_agent_skill_catalog().
[0.2.9.7] - 2026-05-13¶
Added¶
build_datasetgrouped query strategy (grouped=True) (dataset/builder.py): A newgroupedparameter (defaultFalse) activates aMAX(CASE WHEN FEATURE_ID=… THEN FEATURE_VALUE END) … GROUP BYpivot strategy instead of the default one-sub-query- per-feature multi-join approach. Features sharing the same(table, process_id)pair are collapsed into a single sub-query, reducing the number of JOINs from N (one per feature) to one per unique source group. Single-feature groups emit a plainFEATURE_ID = Nscan (no GROUP BY overhead). INNER JOIN semantics are preserved via aHAVINGclause that rejects entities missing any feature in the group. Works with both snapshot (feature_store_time=False) and time-travel (feature_store_time=True) modes — the appropriate temporal qualifier (SEQUENCED VALIDTIMEorVALIDTIME AS OF TIMESTAMP '…') is injected inside each sub-query.- Demo notebooks 20 and 21 (
demos/notebooks/): 20 - Build a Dataset with Grouped Queries (snapshot).ipynb— mirrors notebook 03 withgrouped=True, SQL inspection section, sub-query count comparison, and row-count equivalence validation.21 - Build a Dataset in the past with Grouped Queries (snapshot).ipynb— combinesgrouped=Truewithfeature_store_time=True, confirming bothVALIDTIME AS OF TIMESTAMPandMAX(CASE WHEN)appear in the generated DDL.fs-datasetskill updated (tdfs4ds/skills/fs-dataset/SKILL.md): The skill now covers three parameters that were previously missing:grouped(theMAX(CASE WHEN)pivot strategy added in this release),join_type('INNER'vs'FULL OUTER'), andreturn_query(inspect the generated DDL before executing). The code template shows all three as commented-out options; the Rules section explains when to prefergrouped=Trueand whenjoin_type='FULL OUTER'is appropriate.td-explainskill synced to.claude/skills/— the skill existed intdfs4ds/skills/td-explain/but was missing from the project-level.claude/skills/directory.- Claude Code Skills documentation (
README.md,docs_src/getting-started/installation.md,docs_src/skills/overview.md): All three surfaces now document the Python API and CLI entry point for skill installation, explain the difference between user-level (~/.claude/skills/) and project-level (.claude/skills/) targets, and describe the two teammate sharing patterns (git-committed.claude/skills/vs per-venvtdfs4ds-install-skills). The README gains a new## Claude Code Skillssection with the full skill catalogue table.
Changed¶
export_skillsdefaultoverwritechanged fromTruetoFalse(cli.py):export_skillsis the "add missing skills" function; its default should skip files that already exist at the destination. Passoverwrite=Trueexplicitly for unconditional replacement.install_skillsretainsoverwrite=Trueas its default (upgrade semantics).
Fixed¶
build_datasetreturn_query=Truewas executing side effects (dataset/builder.py): The early-return forreturn_query=Truewas placed aftertdml.execute_sql(query)and the dataset catalog registration calls. Every "inspect SQL" invocation silently re-created the view and re-registered catalog entries. Fixed by moving the early-return to immediately after query generation, before any DDL execution.
[0.2.9.6] - 2026-05-12¶
Fixed¶
build_datasettime-travel (feature_store_time=True) now works correctly (dataset/builder.py): Thorough MCP-validated investigation found two separate Teradata constraints:VALIDTIME AS OF TIMESTAMP '…'cannot be placed beforeASinREPLACE VIEWDDL (Teradata requiresASimmediately after the view name — Error 3707).- An outer temporal qualifier placed after
AS LOCK ROW FOR ACCESSfails with Error 9330 ("ValidTime qualifier requires at least one table with ValidTime") because the qualifier cannot reach temporal tables that are wrapped inside derived-table sub-queries. Fix: mirror the existingSEQUENCED VALIDTIMEpattern exactly — placeVALIDTIME AS OF TIMESTAMP '…'inside each sub-query directly against the temporal feature table, and emit no outer temporal qualifier (the outer view body is a plainSELECT). Verified against Teradata 20.0.0 via EXPLAIN before committing.
[0.2.9.5] - 2026-05-12¶
Fixed¶
build_datasettime-travel fix (superseded by 0.2.9.6) — incorrectly assumed outer qualifier propagates into derived-table sub-queries; replaced immediately.
[0.2.9.4] - 2026-05-12¶
Fixed¶
build_datasettime-travel fix (superseded by 0.2.9.5) — incorrect approach, replaced immediately.
[0.2.9.3] - 2026-05-12¶
Fixed¶
upload_featuresfails withAttributeError: 'NoneType' has no attribute 'fetchall'(feature_store/feature_store_management.py,utils/query_management.py): A refactor ofexecute_queryadded afetch_resultsparameter (defaultFalse) and stopped returning the cursor on the modern teradataml path whenfetch_results=False.GetTheLargestFeatureID(called duringregister_featuresinsideupload_features) still chained.fetchall()directly on the return value, which is nowNone. Fixed by passingfetch_results=Trueto that call site.execute_queryerror handler references undefinedtdfs4ds.DEBUG— the attribute isDEBUG_MODE. Would raiseAttributeErrorwhenever a SQL error triggered the handler. Fixed to usetdfs4ds.DEBUG_MODE.
[0.2.9.2] - 2026-05-12¶
Fixed¶
- Infinite recursion / Jupyter DeadKernelError when accessing
tdfs4ds.genai(tdfs4ds/__init__.py): The__getattr__lazy-loader usedfrom . import genai, which triggersimportlib._handle_fromlist. That function callshasattr(tdfs4ds, 'genai')to check whether the attribute already exists; since it does not yet, Python re-enters__getattr__('genai'), which issues anotherfrom . import genai, producing unbounded recursion. With Python's default 1000-frame limit the resultingRecursionErrorcan crash the Jupyter kernel process before the surroundingtry/exceptinsetup()can catch it, surfacing asDeadKernelError. Fixed by writing aNoneplaceholder intotdfs4ds.__dict__['genai']before the actual import starts._handle_fromlistfinds the attribute already set and skips the recursive lookup. Afterimportlib.import_module('tdfs4ds.genai')returns, the placeholder is replaced with the real module; on failure it is removed and the exception re-raises normally. - The bug was masked in
≤ 0.2.9.0by a typo:setup()calledgenai.documentations_tables_creation()(extras), which raisedAttributeErrorbefore the recursion loop could start. Once the typo was corrected in0.2.9.1the loop became reachable, causing the kernel crash.
[0.2.9.1] - 2026-04-30¶
Fixed¶
- Silent VARCHAR truncation in documentation uploads (
genai/documentation.py):tdml.copy_to_sqlcalls for staging volatile tables inupload_documentation,upload_business_dictionary_objects,upload_business_dictionary_columns, andupload_business_dictionary_sectionsdid not pass explicit column types. teradataml inferred VARCHAR sizes from the dataframe and could default to a width far smaller than the destination'sVARCHAR(32000), silently truncating long values (notablyENTITY_COLUMNS_JSON,BUSINESS_LOGIC_DESCRIPTION,ENTITY_DESCRIPTION,FEATURE_DESCRIPTION,BUSINESS_DESCRIPTION,SECTION_CONTENT) before the MERGE into the destination tables. The truncated value was then unparseable as JSON when read back, surfacing as"Failed to parse JSON from LLM response"inretrieve_documentationand amalformed ENTITY_COLUMNS_JSONwarning. - All five staging-table
copy_to_sqlcalls now pass explicittypes={...}withtdml.VARCHAR(length=32000, charset='LATIN')for description/JSON columns and matching widths for identifier columns. - Backward compatibility: no destination DDL change required — destination
tables were always created with
VARCHAR(32000). The bug was strictly at the staging layer (volatile, recreated on every call), so no schema migration is needed. Rows previously written with truncated values must be repaired by re-running the upload, e.g.document_process_incremental(process_id=..., force_update=True).
[0.2.9.0] - 2026-04-17¶
Added¶
- EXPLAIN execution metrics tracked as first-class signals (
documentation.py): every EXPLAIN analysis now extracts two deterministic counters from the raw Teradata EXPLAIN text and surfaces them alongside the 1–5 scores. n_steps— number of numbered execution steps in the plan (e.g.1) First, ...).n_spool_objects— count of distinctSpool Nobjects referenced in the plan (intermediate materialisations).- Parsed by
_parse_explain_metrics(); both values returned bydocument_sql_query_explain()andrun_explain_documentation(). - Stored in
FS_PROCESS_DOCUMENTATION_EXPLAINvia two new columnsN_STEPSandN_SPOOL_OBJECTS(auto-migrated on first upgrade bydocumentation_tables_creation()/migrate_documentation_explain_schema()— older rows show NULL until re-documented). - Returned by
retrieve_explain_documentation()and propagated throughget_process_info()asEXPLAIN_N_STEPS/EXPLAIN_N_SPOOL_OBJECTS. - Rendered in the HTML documentation panel (
_print_documentation) as two new badges next to the User/Overall scores. - Score Summary table in optimizer reports now reports four metrics
(
query_optimizer.py): User score, Global score, EXPLAIN steps, Spool objects — shown as Before → After (or Input → Simplified → Optimized when simplification applied). Negative deltas on steps/spools are highlighted as improvements. - Score-vs-cost trade-off note in reports: when a rewrite improves the user score but adds steps or spool objects, the report now appends an explanatory note so reviewers understand the optimizer traded a small amount of intermediate materialisation (typically single-row CTEs) for a genuine structural SQL fix.
- Per-round metric tracking in
skill_generate_candidates: every refinement round recordsn_stepsandn_spool_objectsin addition touser_scoreandremaining_you_items— visible in the per-candidate breakdown of the report. - Ground-truth EXPLAIN signal parser
_parse_explain_signals()(documentation.py): extracts deterministic signals from the raw EXPLAIN (presence of real product joins, per-table scan counts, data redistribution) used to cross-validate LLM-generated[You]items before scoring. - Semantic
[You]item classification_classify_you_items()(documentation.py): categorises each[You]item (e.g. real product join vs scalar-subquery false positive) in a single LLM call; items in known false-positive categories are filtered from the deterministic score computation. Regex fallback retained for when the LLM is not configured. classifyparameter added todocument_sql_query_explain(),run_explain_documentation(), and_compute_user_score_from_you_items(): intermediate scoring in the optimizer usesclassify=Falseto keep the raw[You]signal; the final public-facing score (viadocument_process_incremental) usesclassify=Trueto filter noise.- Deterministic tiebreaker in plan comparison (
query_optimizer.py): when two candidates have equal user scores, the optimizer now deterministically picks the one with fewer EXPLAIN steps + Spool objects. Logged as "Tiebreaker: …" with full metric context in the report's reasoning section. - Refinement acceptance rule: a refinement round is rejected when the user score is unchanged but steps/spools regressed — ensures the structurally lightest SQL at each score tier is retained.
- Plan comparison prompt criteria extended: the selection prompt now tells the LLM to prefer candidates with fewer EXPLAIN steps and Spool objects as a tiebreaker when scores are equal (before falling back to readability).
- Simplification baseline preserved (
skill_simplify_query): the full baseline EXPLAIN analysis is now stored in the result dict under_baseline_analysis, enabling the optimizer report's 3-stage table to show pre-simplification steps/spools alongside the user score.
Fixed¶
get_process_infofails when documentation tables return no row (process_store/process_store_catalog_management.py): direct key access (documentation['DOCUMENTED_SQL'], etc.) raisedKeyErrorwhenretrieve_documentation/retrieve_explain_documentationreturned an empty dict. Replaced with.get(...)with safe defaults; the success log line is now gated on a non-empty result so empty-lookup cases no longer claim success.retrieve_explain_documentationschema compatibility: the query now probes the live DDL viaSHOW TABLEand only selectsN_STEPS/N_SPOOL_OBJECTSwhen those columns are present — older installs that haven't run the schema migration continue to work.
Changed¶
migrate_documentation_explain_schema()now multi-column (documentation.py): migratesUSER_SCORE,N_STEPS, andN_SPOOL_OBJECTSin one DDL-probe pass. Idempotent — only runsALTER TABLE ADD <col>for columns actually missing fromSHOW TABLEoutput.upload_documentation_explainNULL-safe: all integer metric columns use a shared_int_or_nullhelper so new EXPLAIN rows writeNULLcleanly when a score or counter could not be computed.
[0.2.8.1] - 2026-04-16¶
Added¶
- SQL simplification step (Step 0.5) in
skill_optimize_query(query_optimizer.py): before any EXPLAIN analysis the optimizer now runs a structural compaction pass that merges unnecessary nesting layers (multiple derived-table wrappers → CTE + single outer SELECT) and rewrites verbose subquery structures into more readable forms — giving the LLM a cleaner baseline to work with and reducing candidate tokens. skill_simplify_query(sql_query, process_id)— new standalone public function. Returns{"simplified_sql", "original_sql", "simplified", "original_score", "simplified_score"}. Accepts raw SQL or aprocess_id; runs deterministic sanitisation, baseline EXPLAIN, LLM compaction, then accepts the result only when the simplified score ≥ original score.simplify_query(sql_query, process_id)— synchronous public wrapper (no LangGraph) exported fromtdfs4dsandtdfs4ds.agent._simplify_sql_sync()— LLM helper; adapted from the retired_compact_sql_syncwith a new CRITICAL SEMANTIC RULE: scalar subqueries must preserve their exact scope (uncorrelated global aggregates must not gain a correlated WHERE clause).- Step 0.5 is recorded in
stepsas{"step": "simplification", "simplified": bool, ...}. - When simplification changes the SQL, the stored EXPLAIN analysis is considered stale and a fresh EXPLAIN is run on the simplified query as the optimizer's new baseline.
- Report
## Simplificationsection: present in every report showing whether compaction was applied, the original vs simplified score, and a collapsible block with the simplified SQL when it differs. - 3-stage query comparison in reports: Input → After Simplification → After Optimization. The "Input Query" section now always shows the original (pre-simplification) SQL; "After Simplification" and "After Optimization" sections follow.
ALWAYS CHECK FOR REDUNDANT TABLE SCANSinstruction in the EXPLAIN prompt (documentation.py): mandatory whole-query structural check. If the same source table is scanned more than once with the same GROUP BY key — as separate CTEs, subqueries, or JOIN branches — the LLM must flag it as a[You]warning regardless of per-step efficiency. Example detected: two CTEs both scanningtransactions GROUP BY CustomerIDthat can be merged into a single conditional-aggregation pass.singlepassstrategy CTE structure rule (query_optimizer.py): thesinglepasstechnique constraint now explicitly instructs the LLM to place the aggregation in a CTE and compute derived columns (ratios, date arithmetic, alias-referencing expressions) in the outer SELECT — preventing expression duplication when SQL aliases cannot be referenced within the same SELECT level.
Fixed¶
- "Query (Unchanged)" label after simplification (
query_optimizer.py): when a simplification pass had already improved the SQL but no further optimization candidate was accepted, the report incorrectly showed "Query (Unchanged)". The report now shows "Input Query / After Simplification" with the correct per-stage scores. - Missing "Remaining Issues" section at selected_idx = -1 (
query_optimizer.py): when no optimization candidate was selected (all candidates regressed or only one strategy was tried and failed), the Remaining Issues section was absent. It is now populated fromoriginal_analysiswhenselected_idx = -1. - Non-actionable
[You]items inflating scores (documentation.py): EXPLAIN sometimes generates[You]items that confirm the query is already correct ("no action needed", "already efficient", "approach is correct", etc.) — these items inflate the issue count and lower the deterministic user score unfairly. A_non_actionableregex filter now removes them before score computation. A matching instruction was added to the EXPLAIN prompt:[You]items must always describe something to fix, never confirm a pattern is already fine. - Duplicate uncorrelated scalar subquery flagged as false positive (
documentation.py): the EXPLAIN scorer was generating[You]items for scalar subqueries appearing more than once in the same SELECT list (e.g.high_value_transaction_countandhigh_value_ratioboth containing the sameSELECT AVG(...) FROM texpression). In Teradata the optimizer evaluates identical uncorrelated scalar subqueries once and reuses the result — this is a SQL-text artifact, not a performance problem. Added to "Do NOT lower the score for" in the EXPLAIN prompt and to the_non_actionablefilter. - Teradata reserved word
DAYcausing Error 3706 (query_optimizer.py): LLM-generated rewrites usingDATEADD(DAY, n, date)or(date1 - date2) DAYcaused"expected something between '(' and the string 'DAY'"becauseDAYis a Teradata reserved word and putting(DAYtogether is always illegal. Added explicit entries to_teradata_sql_guide(): a "DATE minus DATE" section (Teradata subtraction already returns integer days — noDAYqualifier needed) and explicit✗ DATEADD(DAY, ...)/✗ DATEDIFF(DAY, ...)examples noting these are SQL Server functions that do not exist in Teradata. - Error 5480 / Error 3504 alternating fix loop (
query_optimizer.py): when a window function was nested inside another (Error 5480), the LLM fix removed the nesting but placed an aggregate and a window function in the same SELECT + GROUP BY (Error 3504). Added both error patterns with correct separation examples to_teradata_sql_guide(). INSTRUCT_MODEL_URL=Nonerejected for OpenAI provider (documentation.py): the URL presence checkif not INSTRUCT_MODEL_URLwas triggered even when the provider wasopenai(which uses a fixed endpoint and does not require a URL). Fixed by checking_url_required = INSTRUCT_MODEL_PROVIDER not in ('openai', 'bedrock')first.
Removed¶
_compact_sql_sync()and_try_compaction()(query_optimizer.py): retired. The compaction logic is now the pre-optimization simplification step (Step 0.5), which runs on every query rather than only after a perfect 5/5 EXPLAIN. Compaction call sites inskill_generate_candidateshave been removed accordingly.
[0.2.8.0] - 2026-04-14¶
Added¶
- Query Optimizer Agent (
tdfs4ds/agent/query_optimizer.py): LLM-powered SQL rewrite pipeline for Teradata feature-engineering queries. Process-aware — when aprocess_idis supplied, SQL and stored EXPLAIN documentation are pulled directly from the process catalog, avoiding redundant LLM calls. - 8-step pipeline inside
skill_optimize_query:- Process context —
get_process_infofetch (skipped when noprocess_id) - Structured EXPLAIN analysis —
document_sql_query_explainscores the plan 1–5 with[You]-prefixed author-actionable warnings and recommendations - Lineage graph — Primary Index + partition columns collected per underlying object
via
build_teradata_dependency_graph - DDL fetch —
SHOW TABLE/SHOW VIEWfor every referenced object - Candidate generation loop — LLM proposes ≤
Nrewrites focused on[You]-actionable items; near-duplicate guard (SequenceMatcher ≥ 0.97) discards cosmetic reformats; deterministic_try_flatten_identity_wrapperapplied to every candidate before EXPLAIN - Candidate EXPLAIN —
document_sql_query_explainrun per surviving candidate - Plan comparison — LLM selects the best plan; deterministic safeguard overrides a regressing LLM choice and promotes the highest-scoring valid candidate
- FilterManager check — partitioned-but-unfiltered objects flagged; auto-generates
a ready-to-run
FilterManagerloop snippet
- Process context —
query_optimizer()— synchronous LangGraph entry point; multi-turn viaMemorySaversingleton (_get_checkpointer()); retrieves SQL from history on follow-up questions that do not supply new SQLaquery_optimizer()— async wrapper for Jupyter notebooks (avoids background-thread overhead fromasyncio.run)skill_generate_candidates()— exported candidate generation loop; two independent counters (valid_count,failure_count); early-stop only at perfect 5/5 score;QUERY_OPTIMIZER_MAX_FAILUREScaps syntax/EXPLAIN failures separately from the candidate budgetdisplay_optimization_result()— Jupyter display helper: renders a before/after score comparison widget (colour-coded Δ Change cells, strategy and business-logic-preservation footer) followed by the full Markdown reportscore_deltaresult key:optimized,best_strategy,original_user_score,original_global_score,best_user_score,best_global_score,user_score_delta,global_score_delta,business_logic_preserved- Native Teradata tools (no MCP server required): seven read-only metadata
functions over the active teradataml connection —
_td_list_databases,_td_list_tables,_td_list_views,_td_get_object_type,_td_get_table_ddl,_td_get_view_ddl,_td_get_query_explain _teradata_sql_guide(): single source of Teradata syntax rules injected into LLM prompts (date arithmetic, percentile/ordered-set aggregates, INTERVAL literals, NULL/conditional, common Error 3706/3707 patterns)_fix_answer_fences(): post-processes LLM output to close fenced code blocks that the model forgot to close before a Markdown section heading- Three new globals (
tdfs4ds/__init__.py): QUERY_OPTIMIZER_MAX_CANDIDATES(default5) — max valid rewrites to evaluateQUERY_OPTIMIZER_MAX_FAILURES(default5) — max EXPLAIN/syntax failures before the candidate loop stops; failures do not count towardMAX_CANDIDATESQUERY_OPTIMIZER_MAX_CANDIDATE_TOKENS(defaultNone) — hard cap on completion tokens per candidate call; set to e.g.4096for small-context models- Notebook
11 - Query Optimizer Agent with tdfs4ds.ipynb(notebook dev/genai/): end-to-end walkthrough — process-aware entry, multi-turn follow-ups, score comparison widget, and FilterManager recommendation. - README: added Query Optimizer Agent section with pipeline table, quick start,
all result keys,
score_deltastructure,display_optimization_resultusage, and FilterManager integration snippet; added consumer agent notebook references (09, 10); added EXPLAIN→DEFINITION drill-down paragraph andresolved_column_sourcesstate field to the consumer agent section.
[0.2.7.5] - 2026-04-14¶
Added¶
- EXPLAIN→DEFINITION drill-down (
consumer_agent.py,graph.py): after an EXPLAIN turn the agent now remembers every source column mentioned in the feature formula. SourceColumnRefPydantic model capturescolumn_nameandsource_tablefrom the LLM response.ExplainOutputgained asource_columnsfield; the LLM is asked to resolve all distinct column references (resolving through subquery aliases to the real base table/view wherever possible).synthesize_answerappends a "Variables involved" list after the explanation (- \col` (from `table`)`) so users can immediately ask "what is X?" about any input variable.node_skill_explainwrites the column→source-table mapping into the newresolved_column_sourcesfield inAgentState(persisted viaMemorySaver).- BD_COLUMNS secondary definition path (
consumer_agent.py):skill_definitiongained a new lookup path that queriesFS_BUSINESS_DICTIONARY_COLUMNSfor columns that are not registered features (dimension keys, derived columns, etc.). - New optional parameter
hint_table_name: when set (from a prior EXPLAIN turn viaresolved_column_sources), the search is narrowed to the correct table immediately. node_skill_definitionreadsresolved_column_sourcesand passes the matched table ashint_table_name.- Fuzzy follow-up resolution for DEFINITION (
graph.py): new_fuzzy_resolve_from_column_sources(question, col_sources)helper. When the intent classifier extracts no explicitobject_namefor a DEFINITION question, the helper tokenises the user's question (with a bilingual EN/FR stop-word list) and checks whether any token is a substring of a known column name or source table. A match is used as the resolved object name — no need for the user to quote the exact identifier. resolved_column_sourcesinAgentState(graph.py): new optional dict field (keys: uppercase column names; values: bare source table/view names). Displayed in the Gradio trace panel under "Feature Context (remembered)".
Changed¶
connect()round-trip reduction (lifecycle.py): replaced twodb_list_tables()and twoDataFrame(in_schema(...)).columns/get_ddl()calls with a single targetedDBC.TablesVquery and a combinedDBC.ColumnsVquery. Falls back to the original approach automatically whenDBCaccess is restricted.DatasetCatalog._exists()accepts pre-computed object set (dataset_catalog.py): new optionalexisting_objectsparameter. When supplied byconnect(), thedb_list_tables()round-trip inside the constructor is skipped entirely.- Lazy LangChain / sqlparse imports (
genai/documentation.py):langchain_openai,langchain_core, andsqlparseare now imported inside the functions that need them. This removes the 30–40 s startup cost incurred on every plainimport tdfs4dswhen LangChain is installed.
[0.2.7.2] - 2026-04-10¶
Fixed¶
- Multi-turn memory loss (
graph.py):consumer_agent()was callingbuild_graph()on every invocation, creating a freshMemorySavereach time. All cross-turn state (resolved_object_name,resolved_feature_triplet,resolved_data_domain, etc.) was wiped after every question, so follow-up questions like "when has this feature been updated?" could not resolve back to the feature discussed in the previous turn. Fixed by introducing a module-level_graph_instancesingleton via_get_graph()— the compiled graph and itsMemorySaverare now built once and reused for the lifetime of the process. - Ambiguous-feature prompt on contextual follow-ups (
consumer_agent.py): when a feature name exists across multiple entity types and the user asked a follow-up question without repeating the entity,skill_freshnessandskill_definitionreturnedambiguous_featureand asked for clarification even though the entity was already established from a previous turn. Fixed by passinghint_entity_namefromresolved_entity_namestate: if the hint uniquely identifies one row in the catalog, disambiguation is silent and the skill proceeds normally.
Added¶
- Entity and feature-group context in
AgentState(graph.py): two new persisted fields carried byMemorySaveracross turns: resolved_entity_name— entity currently in focus (e.g."CustomerID"); updated whenever a feature triplet is resolved.resolved_feature_list— list of feature names currently in focus; holds a single name after a single-feature resolution, or the full entity feature list after aSUMMARYturn covering one entity.skill_freshness/skill_definitionaccepthint_entity_name(consumer_agent.py): optional parameter used for silent disambiguation (see fix above).skill_summaryreturns feature group context (consumer_agent.py): the result dict now includesfeature_names(flat list) andfeature_names_by_entity(entity → list of feature names).node_skill_summaryuses these to updateresolved_feature_listand, when exactly one entity is covered,resolved_entity_name.- Trace panel shows feature-group size (
graph.py): whenresolved_feature_listholds more than one feature, the Feature Context section of the agent trace displays the count and names of all features in focus.
[0.2.7.1] - 2026-04-10¶
Fixed¶
- Intent classifier — verbatim extraction of feature name and domain
(
consumer_agent.py): theintent_classifiersystem prompt now explicitly instructs the LLM to copy both the feature/dataset name and the domain name exactly as typed (no spelling correction, no pluralisation change, no case normalisation). Previously the LLM would silently "fix" names it considered misspelled (e.g.nb_days_since_last_transactions→nb_days_since_last_transaction), causing catalog lookups to fail on perfectly valid feature names. The same risk applied to domain abbreviations (e.g.ATRexpanded to a full word), which would causeskill_summaryto summarise under the wrong label. process_view_not_founderror response (synthesize_answer): the synthesizer is now explicitly told not to suggest alternative spellings or guess at correct names — it can only see what the skill returned, so any suggestions it invented were hallucinations.
Added¶
- Fuzzy feature-name matching (
consumer_agent.py): new helper_fuzzy_match_feature(object_name)usesdifflib.get_close_matches(stdlib, no new dependency) with a similarity cutoff of 0.75 against the full feature catalog across all data domains. Called automatically when an exact lookup fails inskill_explain,skill_lineage, andskill_freshness. - Single close match → the skill auto-corrects to the catalog name, runs
normally, and includes a
correction_notein the result dict. - Multiple close matches → returns a new
feature_not_found_did_you_meanerror with the candidate list (no answer is attempted). feature_not_found_did_you_meanerror handler (synthesize_answer): new error path that tells the user the exact name was not found and lists the similar catalog names found — without guessing what those features do.- Auto-correction notice in answers: when a single-candidate auto-correction
is applied,
synthesize_answerprepends an italic note to the direct answer, e.g. 'nb_days_since_last_transactons' was not found — answered for the closest match 'nb_days_since_last_transactions'.
[0.2.7.0] - 2026-04-09¶
Added¶
- Consumer agent module (
tdfs4ds/agent/): conversational chatbot for business consumers of the feature store. Non-technical users can ask natural-language questions about features, datasets, definitions, data freshness, usage guidance, data lineage, and calculation logic — in English or French. - Intent classifier (
consumer_agent.py): uses Pydantic structured output viallm.with_structured_output()to route questions to one of 7 skills: SEARCH, DEFINITION, USAGE, FRESHNESS, SUMMARY, LINEAGE, EXPLAIN. - 7 skills (
consumer_agent.py):skill_search(semantic vector search),skill_definition(process view → column docs),skill_usage(audience, granularity, regulatory guidance),skill_freshness(latest execution timestamps),skill_summary(full feature list with descriptions),skill_lineage(upstream dependency graph with LLM summary),skill_explain(SHOW VIEWDDL → plain- language business explanation). - LangGraph orchestration (
graph.py):StateGraphwith conditional routing (classify → detect_domain → skill → synthesize → END) andMemorySavercheckpointer for multi-turn conversations. - DATA_DOMAIN auto-detection (
graph.py):node_detect_domainnode scans all DATA_DOMAINs viafeature_catalog(all_data_domains=True)to find which domain owns the requested object. Storesresolved_data_domainin LangGraph state; persisted across turns via MemorySaver so follow-up questions reuse the last resolved domain. All skill nodes settdfs4ds.DATA_DOMAINvia a context manager. New public functions:get_data_domain(),detect_data_domain_for_object(). - Feature description resolution chain (
consumer_agent.py):_get_feature_catalog_with_docs()resolves feature descriptions viaget_list_entity → get_list_features → get_feature_versions → VIEW_NAME → FS_BUSINESS_DICTIONARY_COLUMNS. Used by DEFINITION and SUMMARY. - Embedding factory (
embedding.py):get_embeddings()returns a LangChainEmbeddingsinstance; supports openai/vllm/azure/bedrock providers. Falls back fromEMBEDDING_MODEL_*toINSTRUCT_MODEL_*if not set.list_embedding_models(sub_paths=...)queries available models on the embedding endpoint. - Chroma vector index (
vector_index.py):build_vector_index(force_rebuild=False)builds three collections (objects, sections, columns) from the business dictionary. Incremental by default. Documents are embedded in batches of 8 to respect TEI server limits.search_vector_index(query, sources, top_k)searches with optionalsection_namefiltering and deduplicates onOBJECT_NAME. - Gradio chatbot (
chatbot.py):launch_chatbot()starts a Gradio Blocks UI with JupyterHub proxy support.launch_chatbot_with_index(port, force_rebuild, ...)builds/refreshes the Chroma vector index then launches the Gradio UI — single command for end-to-end startup. - Agent trace panel (
chatbot.py): collapsiblegr.Accordionshowing Intent Classification, DATA_DOMAIN Detection (available / active / source), Skill Executed, and Skill Result. Errors include per-step diagnostic messages.consumer_agent(return_trace=True)returns(answer, trace_md). - Model listing:
list_instruct_models()andlist_embedding_models()query the configured API endpoints for available models. - Demo notebooks:
09 - Consumer Agent Chatbot with tdfs4ds.ipynb(architecture, 7-intent test suite, 4-turn multi-turn demo) and10 - Launch Consumer Agent Chatbot.ipynb(minimal 4-cell one-command launch). - Unit tests:
tests/test_consumer_agent.py— 17 tests covering intent classification, skills, embeddings, vector index, graph routing, and multi-turn. document_feature_store_incremental()(tdfs4ds/genai/documentation.py): single-pass, topo-sorted documentation of every registered process and dataset. Builds a combined lineage graph seeded from all dataset DDLs (expand_datasets_via_process_catalog=True) and any orphan processes; topologically sorts it (leaves first, roots last); then walks the order callingdocument_process_incrementalanddocument_dataset_incrementalwith a shared pair of visited-sets so each process view is documented at most once regardless of how many datasets reference it. Returns a summary dict withprocesses_documented,datasets_documented,processes_skipped,datasets_skipped. Supportsforce_update,all_data_domains, and fallback documentation for objects absent from the graph.- Business dictionary mirroring (
upload_documentation): after persisting process documentation to the process-documentation tables, the function now also writes: - The entity description to
FS_BUSINESS_DICTIONARY_OBJECTS(type'V') - Per-feature descriptions to
FS_BUSINESS_DICTIONARY_COLUMNS(keyed by view name + feature name) Both writes are best-effort — failures are logged as warnings without aborting. - Shared visited-sets in
document_dataset_incremental: new optional private parameters_visited_processesand_visited_viewsallow an outer orchestration call (e.g.document_feature_store_incremental) to pass a shared recursion guard, preventing duplicate documentation when multiple datasets share upstream process views. - Embedding model configuration variables (
tdfs4ds/__init__.py,config.py):EMBEDDING_MODEL_PROVIDER,EMBEDDING_MODEL_URL,EMBEDDING_MODEL_API_KEY,EMBEDDING_MODEL_MODEL,EMBEDDING_MODEL_DIM— mirror theINSTRUCT_MODEL_*pattern. All loadable from.env/tdfs4ds.json/TDFS4DS_*env vars. - Chroma configuration variables:
CHROMA_MODE('local'/'server'),CHROMA_PATH,CHROMA_HOST,CHROMA_PORT. - Claude Code skill:
fs-agent— generates boilerplate for the consumer agent chatbot workflow.
Changed¶
skill_definition: primary path now resolves via the process documentation chain (process view → column docs) instead of querying the feature name directly in the objects table. Falls back to dataset-level lookup, then vector search.skill_summary: replaced barefeature_catalog()call (no descriptions) with_get_feature_catalog_with_docs()so each feature line includes its business description from process documentation.synthesize_answer: DEFINITION, USAGE, FRESHNESS, SUMMARY, LINEAGE, EXPLAIN now short-circuit to their skill's final answer key (no second LLM pass). Only SEARCH still uses the generic synthesis path.skill_freshnessdiagnostics: all three failure points (feature_catalog miss, process_catalog miss, empty followup) now return adiagnostickey with per-step detail instead of collapsing to the same opaqueno_execution_historyerror._get_process_view: primary lookup is nowfeature_catalog → PROCESS_ID → process_catalog → VIEW_NAME. TheFEATURE_NAMEScomma-list search is kept as a fallback only.setup.py: addedlangchain_chroma,langchain_teradata,langgraph,chromadb,pydantic,gradiotoinstall_requires.fs-setupskill: added commented embedding model and Chroma configuration blocks.fs-documentskill: added rule to rebuild vector index after documenting.
[0.2.6.5] - 2026-04-07¶
Added¶
- Dataset documentation (
tdfs4ds/genai/documentation.py):document_dataset_incremental(dataset_id)generates documentation for a dataset by walking its full lineage bottom-up. - Source tables: uses business dictionary if available, warns if missing.
- Intermediate views: auto-documented via LLM if undocumented.
- Process views: actively calls
document_process_incrementalif undocumented. - Feature/entity column descriptions are propagated from process documentation (no redundant LLM call for 1:1 feature mappings).
- A single JSON-constrained LLM call generates five structured sections for the dataset itself: OVERVIEW, ENTITY, FEATURE_THEMES, BUSINESS_QUESTIONS, INTENDED_AUDIENCE.
-
Notebook
08 - Dataset documentation with tdfs4ds.ipynb(notebook dev/genai/). -
Business dictionary sections table (
FS_BUSINESS_DICTIONARY_SECTIONS): new temporal table keyed on(DATABASE_NAME, OBJECT_NAME, SECTION_NAME). Each row stores one section of an object's documentation as an independent RAG document. Together with the existing objects and columns tables, this forms a 3-level hierarchy designed for chunking-free hierarchical retrieval: - Level 0:
FS_BUSINESS_DICTIONARY_OBJECTS— object summary (OVERVIEW) - Level 1:
FS_BUSINESS_DICTIONARY_SECTIONS— one row per section - Level 2:
FS_BUSINESS_DICTIONARY_COLUMNS— one row per column - New upload function:
upload_business_dictionary_sections(df). - New helper:
_fetch_sections_for_node(db, obj). - Table created automatically on next
setup()orconnect(create_if_missing=True).
Changed¶
_format_upstream_context: type label for'F'changed from'Feature Store Dataset'to'Feature Store Process'(datasets use type'D').
[0.2.6.4] - 2026-04-03¶
Added¶
- Dataset lineage via process catalog (
tdfs4ds/lineage/network.py): newexpand_datasets_via_process_catalogparameter onbuild_teradata_dependency_graph(defaultTrue). When a dataset node is detected the graph parses the dataset view DDL to extractFEATURE_VERSIONUUIDs (each is aPROCESS_ID), looks them up inFS_V_PROCESS_CATALOG, and adds edges directly to the registered feature-engineering views — bypassing the internalFS_T_...storage tables.
Setexpand_datasets_via_process_catalog=Falseto restore the previous leaf behaviour (dataset node connected directly to its raw storage tables).
Expected lineage chain: dataset → process view(s) → source table(s). graph_to_migration_manifest(graph, *, filter_database=None)(tdfs4ds/lineage/network.py, exported fromtdfs4ds.lineage): converts a lineage graph into a JSON-serialisable migration manifest with three keys:"views"—{database, name, type}entries for everyview/datasetnode"tables"—{database, name, type, primary_index, partition_columns}entries"edges"—{from, to}dependency pairs
Passfilter_databaseto scope output to a single feature store schema; cross-boundary edges are excluded automatically.- Notebook
07 - Dataset Lineage via Process Catalog.ipynb(notebook dev/lineage/): demonstrates both behaviours side-by-side and the migration manifest export.
Fixed¶
- Orphaned
FS_T_...storage-table nodes are pruned from the graph result after their edges are replaced by process-view edges. - Pre-added edges from a dataset root to its raw storage tables are purged only
when
expand_datasets_via_process_catalog=True; whenFalsethose edges are preserved and the old output is unchanged.
[0.2.6.3] - 2026-04-03¶
Added¶
- Business dictionary (
tdfs4ds/genai/documentation.py): two new temporal tables and corresponding upload functions for storing business-oriented descriptions independently of the process documentation workflow. No LLM required — descriptions are plain text supplied by the user in a pandas DataFrame. FS_BUSINESS_DICTIONARY_OBJECTS— object-level descriptions (DATABASE_NAME,OBJECT_NAME,OBJECT_TYPE'T'/'V',BUSINESS_DESCRIPTION). Temporal VALIDTIME table.FS_BUSINESS_DICTIONARY_COLUMNS— column-level descriptions (DATABASE_NAME,TABLE_NAME,COLUMN_NAME,BUSINESS_DESCRIPTION). Temporal VALIDTIME table.upload_business_dictionary_objects(df)— inserts or updates object descriptions viaCURRENT VALIDTIME MERGE; raisesValueErroron missing columns.upload_business_dictionary_columns(df)— inserts or updates column descriptions viaCURRENT VALIDTIME MERGE; raisesValueErroron missing columns.- Both tables are provisioned automatically by
documentation_tables_creation(), called fromsetup()andconnect(create_if_missing=True). connect()accepts two new optional keyword arguments —documentation_business_dictionary_objectsanddocumentation_business_dictionary_columns— to override the default table names, consistent with all other catalog parameters.- External configuration support (
tdfs4ds/config.py): global variables can now be set from three external sources, eliminating the boilerplate setup cell that every notebook previously required. - JSON config file (
tdfs4ds.json/~/.tdfs4ds/config.json) — project defaults for non-sensitive settings. Keys are case-insensitive. .envfile (./.env/~/.tdfs4ds/.env) — local overrides and credentials. OnlyTDFS4DS_*variables are read; the file is parsed without modifyingos.environ. Add.envto.gitignoreto keep secrets out of source control. SupportsKEY=VALUE, quoted values, andexport KEY=VALUE.- OS environment variables — prefix
TDFS4DS_, e.g.TDFS4DS_SCHEMA,TDFS4DS_DATA_DOMAIN. Intended for CI/CD pipelines. INSTRUCT_MODEL_API_KEYis rejected from JSON config files (credential safety); use.envor an OS env var instead.tdfs4ds.load_config(path=None, dotenv_path=None)— public function to (re-)load config at any point, or to load from non-default file paths.- Priority chain (highest first): programmatic → OS env var →
.envfile → JSON config → teradataml auto-detection (SCHEMA) → built-in defaults.
Changed¶
SCHEMAauto-detection from the teradataml context now only runs whenSCHEMAis stillNoneafter config/env loading, so env-var or file settings are not silently overwritten on import.
[0.2.6.2] - 2026-03-26¶
Fixed¶
- tqdm rendering in VSCode Jupyter: switched all
import tqdm/tqdm.tqdm(...)usages inentity_management.py,feature_store_management.py, andutils/lineage.pytofrom tqdm.auto import tqdm.tqdm.autoselects the widget renderer in JupyterLab/VSCode Jupyter and falls back to the standard terminal bar elsewhere.
[0.2.6.1] - 2026-03-26¶
Fixed¶
- Teradata Error 6706 when uploading LLM-generated documentation to LATIN-charset columns.
Added
_sanitize_for_teradata()ingenai/documentation.pythat replaces common Unicode characters produced by LLMs (smart quotes, em/en dashes, ellipsis, bullet points, non-breaking spaces) with ASCII equivalents, then encodes the remainder as ISO-8859-1. Applied in bothupload_documentationandupload_documentation_explain.
[0.2.6.0] - 2026-03-26¶
Changed¶
- Reshape: refactored
tdfs4ds/__init__.pyinto dedicated submodules for better maintainability (catalog.py,data_domain.py,execution.py,lifecycle.py,dataset/builder.py). Public API surface is unchanged. - Added Claude Code skill definitions (
.claude/skills/,skills/) for feature-store workflows:fs-setup,fs-upload,fs-inspect,fs-filter,fs-rollout,fs-dataset,fs-document,fs-lineage,fs-analyze. - Extended demo notebooks and added pre-executed notebook exports under
demos/notebooks_executed/.
[0.2.5.4] - 2025¶
Added¶
- Lineage module: SQL query parsing, Teradata DDL analysis, dependency graph construction, and Sankey diagram visualisation.
- Primary-index and partition-column inspection for source tables.
- AWS Bedrock support in the GenAI documentation module.
[0.2.5.3] - 2025¶
Added¶
- Retrieve and update stored documentation from documentation tables.
- Pretty-print display of process documentation (
display_process_info). - Manual documentation entry via chatbot output (
feed_process_info_with_prompt_result).
Fixed¶
- Bug fixes in documentation table creation and volatile-table drop logic.
[0.2.4.45] - 2025¶
Added¶
- GenAI module (
genai/documentation.py): LLM-powered auto-documentation of SQL processes using LangChain with OpenAI or AWS Bedrock. document_process(): generates business-logic description, entity description, entity-column annotations, and feature-column descriptions from a process view.get_the_explain()+document_sql_query_explain(): EXPLAIN-plan retrieval and LLM-powered optimisation analysis with warnings and recommendations.upload_documentation()/upload_documentation_explain(): persist generated documentation into temporal documentation tables.documentation_tables_creation(): provisions the documentation catalog tables.- Support for
json_constraintmode (structured JSON output) andprompt_onlymode (returns prompt without calling the LLM).