Final fix-wave items C1, I1-I4 and B11 (refined), all landing in the
same handful of interconnected files (llm.lineage/llm.rag/llm.evidence
share the collapse/select/label/budget call paths, so they can't be
split into independently-working commits):
- C1 (critical): llm.lineage._store() was one process-global bib.Store
whose sqlite connection is check_same_thread=True — /chat runs each
turn in Starlette's threadpool, so every thread but the first got a
silently inert (or raising) store. Now threading.local(), one Store
opened lazily per thread. rag._docket_year uses the same accessor and
is now fully guarded (never escapes era_of). New
TestConcurrentLineage (8 threads x 40 calls, mirrors
TestConcurrentChats) asserts no cross-thread error and one Store
construction per thread.
- I1 (Ruling B12): _collapse's tie-break is now
(not anchored_fr, is_proposed, p_id, item_key) — a final rule beats a
tied proposed one, and a fully-tied pair is decided by item_key for a
deterministic total order.
- I2 (Ruling B13): rule_label carries "{vol} FR {page}" when the
paragraph resolves (via the cached item/paragraph lookups), making
labels unique across paragraphs that used to share one.
merge_sources dedupes rule-kind rows on (item_key, p_id) instead of
label, since two rule chunks for the same paragraph can now carry
different labels.
- I3 (Important): era_balance picks eras evenly across the range when
there are more distinct eras than top_n, so the round-robin (which
visits newest-first every round) doesn't silently drop the oldest
eras from a wide history question.
- I4 (Important): _SYSTEM's opener, refusal clause and recency
guidance are reworded to match what the prompt actually contains
(excerpts + optional Lineage + optional Valuation), and the Lineage
paragraph now precedes Valuation to match build_messages' order.
- B11 refined (whole-branch review): a hard per-turn code cap
(chat_codes_max=24, explicit codes then family order —
llm.evidence.cap_codes, shared by valuation_evidence and
lineage_evidence so both cap the same question identically),
a valuation-rows cap (valuation_rows_max=24, explicit then newest
vintage), a lineage-rows hard cap (2x lineage_max_rows, priority
rows capped at 4/code), element-diff code lists compacted past 8,
manual sources capped at 2, and build_messages(budget_chars=...)
which drops lineage-source excerpts>4, retrieved>6, cited>8,
manual>1, valuation rows>12, then lineage rows>lineage_max_rows in
that order until the assembled prompt fits — wired into
stream_answer as budget_chars=cfg.chat_num_ctx*3. The
_LINEAGE_SOURCES_HARD_CAP is now enforced in lineage_sources' own
event loop, not only its element-diff tail.
I5: TestLineageEvidenceLive now uses the shared restore_families
fixture (moved to tests/conftest.py) so opening the real replica
doesn't leak thousands of derived families into later tests.
Diagnosing this also turned up a second, pre-existing leak of the same
shape: TestStreamAnswer's control-question test ran the real
lineage_evidence against CFG's default (real, 3GB)
data/replica/aco.ro.duckdb, since llm.lineage.lineage_evidence calls
evidence.warm(cfg) unconditionally before checking for detected codes
— fixed by pointing that one test at a nonexistent replica path
(exactly the "without touching the replica" behavior its own docstring
already claimed).
Verified: uv run pytest tests/llm tests/pfs/test_families.py
tests/pfs/test_lineage.py tests/cli/test_pfs_cli.py tests/dev -q
-p no:cacheprovider -m "not live" — 583 passed. In-process golden run
against the live replica: 3/7 pass (ccm-history, audio-only-em-99441,
g2211-commenters); g2058-replacement/apcm-vs-ccm/99490-telehealth
unchanged documented gaps; g2064-g2065 newly misses one of its three
anchors (JE7KYBW3 p1111) specifically because of the new 4-per-code
lineage-row cap this commit adds (Ruling B11) — an accepted tradeoff
of the budget work, not a bug. Prompt-size check (budget 24,576
chars): "history of CCM coding and payment" 20,670 chars; the 58-code
three-family history question 16,135 chars (58 detected codes capped
to 24) — both under budget.
321 lines
10 KiB
TOML
321 lines
10 KiB
TOML
# stack.toml — centralised configuration for the stack platform.
|
||
#
|
||
# Paths are relative to the repository root unless absolute.
|
||
# Secrets live in .env (never here).
|
||
#
|
||
# Context switching
|
||
# -----------------
|
||
# [context.active] selects the environment profile. Override at
|
||
# runtime with STACK_CONTEXT=lake (env var beats this file).
|
||
#
|
||
# Each context sets db_backend and storage_backend. Modules call
|
||
# conf.context() to decide *which* engine or filesystem to use;
|
||
# the [db], [storage], and [lake] sections supply the concrete
|
||
# connection details for the selected backend.
|
||
|
||
[platform]
|
||
domain = "fhirworx.io"
|
||
registry = "git.fhirworx.io"
|
||
image_prefix = "fhirworx"
|
||
repo = "homelab/stack"
|
||
ci_email = "ci@fhirworx.io"
|
||
host_ip = "192.168.1.192"
|
||
subdomains = [
|
||
"dashboard", "docs", "git", "ci", "notebooks", "zotero",
|
||
"webdav", "api", "nessie", "trino", "polaris",
|
||
"grafana", "prometheus", "tempo", "loki",
|
||
"s3", "s3console", "llm",
|
||
]
|
||
|
||
[services]
|
||
git = "http://git:3000"
|
||
rustfs = "http://rustfs:9000"
|
||
nessie = "http://nessie:19120"
|
||
polaris = "http://polaris:8181"
|
||
trino = "http://trino:8080"
|
||
grafana = "http://grafana:3000"
|
||
ollama = "http://ollama:11434"
|
||
|
||
[context]
|
||
active = "local"
|
||
|
||
[context.local]
|
||
db_backend = "duckdb"
|
||
storage_backend = "local"
|
||
|
||
[context.lake]
|
||
db_backend = "iceberg"
|
||
storage_backend = "s3"
|
||
catalog = "nessie"
|
||
|
||
[context.databricks]
|
||
db_backend = "databricks"
|
||
storage_backend = "dbfs"
|
||
|
||
[context.trino]
|
||
db_backend = "trino"
|
||
storage_backend = "s3"
|
||
|
||
[context.aws]
|
||
db_backend = "iceberg"
|
||
storage_backend = "s3"
|
||
catalog = "glue"
|
||
compute = "ecs"
|
||
|
||
[context.gcp]
|
||
db_backend = "iceberg"
|
||
storage_backend = "gcs"
|
||
catalog = "bigquery"
|
||
compute = "cloud-run"
|
||
|
||
[context.azure]
|
||
db_backend = "iceberg"
|
||
storage_backend = "abfs"
|
||
catalog = "unity"
|
||
compute = "container-apps"
|
||
|
||
[db]
|
||
aco = "data/aco.duckdb"
|
||
bib = "data/bib.sqlite"
|
||
zotero = "data/zotero/data/zotero.sqlite"
|
||
|
||
[comments]
|
||
seal_quiet_days = 30 # days after a docket's comment close date before an empty pull seals it
|
||
|
||
[storage]
|
||
bib = "data/bib/storage"
|
||
zotero = "data/zotero/data/storage"
|
||
bcda = "data/bcda"
|
||
rex = "data/rex"
|
||
pfs = "data/pfs"
|
||
rec = "data/rec"
|
||
cms_log = "data/cms/log.jsonl"
|
||
bcda_log = "data/bcda/log.jsonl"
|
||
|
||
[generate]
|
||
table_out = "src/aco/table"
|
||
base_import = "aco.table.base"
|
||
|
||
[bcda]
|
||
sandbox = "https://sandbox.bcda.cms.gov"
|
||
production = "https://api.bcda.cms.gov"
|
||
max_retries = 3
|
||
retry_interval = 1.0
|
||
token_lifetime = 1200
|
||
timeout = 120.0
|
||
|
||
[llm]
|
||
ollama = "http://127.0.0.1:11434" # host-side default; containers override via LLM_OLLAMA_HOSTS
|
||
embed_model = "nomic-embed-text" # 768-dim; bake-off (P33 #564) may revise
|
||
instruct_model = "qwen2.5:14b" # baseline, pulled on every fleet host
|
||
instruct_model_large = "qwen2.5:32b" # used when the chosen host declares >= large_min_vram_gb
|
||
large_min_vram_gb = 20
|
||
chat_num_ctx = 8192 # passed per request; no Modelfile ctx variants
|
||
embed_dim = 768
|
||
# HNSW index build. Safe since the postgres image ships an AVX-512-free
|
||
# pgvector rebuild (infra/images/postgresql.Dockerfile, #580); before that it
|
||
# SIGILLed this Zen2 host. Set false to fall back to exact search.
|
||
build_ann_index = true
|
||
pg_host = "127.0.0.1" # host-side default; containers set LLM_PG_HOST=postgres
|
||
pg_port = 5432
|
||
pg_db = "llm"
|
||
pg_user = "llm"
|
||
# Retrieval: similarity × recency blend (half-life in days), sources kept
|
||
recency_half_life_days = 365
|
||
recency_weight = 0.3
|
||
top_n = 8
|
||
timeline_per_era = 2 # era_balance: hits kept per rule year on the first pass (mode="timeline")
|
||
timeline_overfetch = 6 # era_balance: k multiplier per kind for timeline mode (vs the recent-mode ×3)
|
||
duckdb_replica = "data/replica/aco.ro.duckdb" # read-only DuckDB replica the chat reads valuations from
|
||
valuation_years = 4 # final-rule vintages shown per code (plus the newest NPRM)
|
||
code_cited_per_code = 3 # excerpts literally citing each detected code (or family)
|
||
code_cited_collections = ["rules", "comments", "corpus"] # searched in this order
|
||
code_cited_max = 12 # cited excerpts kept after round-robin interleave across collections; <= 0 = unlimited
|
||
lineage_max_rows = 25 # collapsed lineage events kept in the prompt block (the SSE payload always carries every collapsed row)
|
||
lineage_on_demand_max = 3 # detected codes per turn allowed to fall back to pfs.lineage.lineage() when pfs.code_event has no rows for them
|
||
lineage_sources_max = 8 # FR paragraphs fetched as sources for events selected into the lineage prompt block, outside code_cited_max (Ruling B10: + up to 2 element-diff anchors, hard cap 10 total)
|
||
chat_codes_max = 24 # Ruling B11: per-turn cap on detected codes (explicit codes first, then family order) — applied before valuation/lineage/code-cited queries
|
||
valuation_rows_max = 24 # Ruling B11: valuation rows kept in the prompt block (explicit codes first, then newest vintage); the SSE payload always carries every row
|
||
|
||
[llm.k_per_kind] # over-fetched ×3 per kind, then re-ranked
|
||
comment = 8
|
||
rule = 4
|
||
corpus = 4
|
||
|
||
[s3]
|
||
endpoint = "http://rustfs:9000"
|
||
region = "us-east-1"
|
||
|
||
[lake]
|
||
warehouse = "s3://lakehouse/"
|
||
|
||
[lake.nessie]
|
||
catalog_uri = "http://nessie:19120/iceberg/"
|
||
|
||
[lake.polaris]
|
||
catalog_uri = "http://polaris:8181/api/catalog"
|
||
|
||
[lake.databricks]
|
||
catalog_uri = "" # set via DATABRICKS_HOST env var
|
||
warehouse = "main" # Unity Catalog name
|
||
catalog = "aco" # default catalog for table references
|
||
|
||
[lake.trino]
|
||
host = "trino"
|
||
port = 8080
|
||
catalog = "iceberg"
|
||
|
||
# DuckLake — the concurrent reference-data store chosen in the M3 spike
|
||
# (docs/superpowers/specs/2026-07-10-ducklake-vs-iceberg-decision.md).
|
||
# The postgres catalog serializes concurrent writers; data is Parquet on
|
||
# RustFS. Password comes from the POSTGRES_PASSWORD env var; the catalog
|
||
# host is compose-internal, so lake writers run inside a data-network
|
||
# container.
|
||
[lake.ducklake]
|
||
catalog = "postgres:dbname=ducklake host=postgres user=postgres"
|
||
# Read-only role for notebook readers (password: DUCKLAKE_RO_PASSWORD).
|
||
catalog_ro = "postgres:dbname=ducklake host=postgres user=ducklake_ro"
|
||
data_path = "s3://lakehouse/ducklake/"
|
||
s3_endpoint = "rustfs:9000"
|
||
|
||
# ── Databricks Asset Bundle generation ────────────────────────────
|
||
# gen_config.py reads this to produce databricks.yml.
|
||
# Add a new pipeline module → commit → databricks.yml updates automatically.
|
||
|
||
[databricks]
|
||
bundle_name = "stack"
|
||
package_name = "stack"
|
||
entry_point = "cli"
|
||
schedule = "0 0 6 * * ?"
|
||
timezone = "America/New_York"
|
||
|
||
[databricks.clusters.default]
|
||
spark_version = "15.4.x-scala2.12"
|
||
node_type_id = "i3.xlarge"
|
||
num_workers = 1
|
||
|
||
[databricks.targets.dev]
|
||
mode = "development"
|
||
default = true
|
||
catalog = "aco_dev"
|
||
|
||
[databricks.targets.staging]
|
||
catalog = "aco_staging"
|
||
|
||
[databricks.targets.prod]
|
||
mode = "production"
|
||
catalog = "aco"
|
||
|
||
[databricks.warehouse]
|
||
name = "stack-sql-warehouse"
|
||
size = "SMALL"
|
||
auto_stop_mins = 10
|
||
|
||
[databricks.secrets]
|
||
scope = "stack"
|
||
mapping = [
|
||
{ env = "BCDA_CLIENT_ID", key = "bcda-client-id" },
|
||
{ env = "BCDA_CLIENT_SECRET", key = "bcda-client-secret" },
|
||
]
|
||
|
||
[databricks.governance]
|
||
phi_schemas = ["core", "claims_preprocessing", "cclf", "input_layer"]
|
||
|
||
[databricks.governance.groups.analysts]
|
||
schemas = ["readmissions", "quality_measures", "cms_quality_measures", "ahrq_measures"]
|
||
privileges = ["SELECT"]
|
||
|
||
[databricks.governance.groups.pipeline_svc]
|
||
schemas = ["*"]
|
||
privileges = ["ALL_PRIVILEGES"]
|
||
|
||
[databricks.quality]
|
||
monitored_schemas = ["core", "claims_preprocessing", "data_quality"]
|
||
output_schema = "_monitoring"
|
||
|
||
[default]
|
||
target = "local" # duckdb | databricks | trino | lake
|
||
|
||
[api]
|
||
host = "0.0.0.0"
|
||
port = 8000
|
||
secret = "" # override via STACK_API_SECRET env var
|
||
workers = 1
|
||
|
||
[ci]
|
||
backend = "gitea"
|
||
coverage_threshold = 99
|
||
|
||
[ci.github]
|
||
runner = "ubuntu-latest"
|
||
registry = "ghcr.io"
|
||
uv_version = "latest"
|
||
|
||
[ci.gitea]
|
||
runner_labels = ["ubuntu-latest"]
|
||
registry_secret = "REGISTRY_TOKEN"
|
||
uv_version = "latest"
|
||
ci_registry = "git:3000" # internal HTTP endpoint (avoids self-signed TLS)
|
||
|
||
[telemetry]
|
||
enabled = false
|
||
endpoint = "http://otel-collector:4317"
|
||
fallback = "traces/spans.jsonl"
|
||
sample_rate = 1.0
|
||
service_name = "stack"
|
||
|
||
[lint]
|
||
line_length = 88
|
||
|
||
# ── Container images ──────────────────────────────────────────────
|
||
# Single source of truth for all built images.
|
||
# gen_config.py reads this to generate pipeline YAML, infra-ci
|
||
# hadolint steps, trivy scans, S3 uploads, and vuln report loops.
|
||
# Add a new image here → commit → all pipelines update automatically.
|
||
|
||
[images.defaults]
|
||
scan = true
|
||
hadolint = true
|
||
trivy_severity = "HIGH,CRITICAL"
|
||
trivy_exit_code = 0
|
||
|
||
# Repo-root context: the Dockerfile COPYs infra/marimo/theme, which only
|
||
# exists at repo root (see .dockerignore carve-outs). Building from notebooks/
|
||
# fails with `"/infra/marimo/theme": not found`.
|
||
[images.notebooks]
|
||
dockerfile = "infra/images/notebooks.Dockerfile"
|
||
context = "."
|
||
path_filter = ["notebooks/**", "infra/marimo/**", "infra/images/notebooks.Dockerfile"]
|
||
|
||
# Repo-root context: the Dockerfile COPYs the desktop-bridge watchdog +
|
||
# healthcheck from infra/images/zotero/ (carved out in .dockerignore).
|
||
[images.zotero]
|
||
dockerfile = "infra/images/zotero.Dockerfile"
|
||
context = "."
|
||
path_filter = ["infra/images/zotero.Dockerfile", "infra/images/zotero/**"]
|
||
|
||
[images.docs]
|
||
dockerfile = "infra/images/docs.Dockerfile"
|
||
context = "."
|
||
path_filter = ["docs/**", "infra/images/docs.Dockerfile"]
|
||
prep_commands = [
|
||
"mkdir -p data",
|
||
"cp /host-data/bib.sqlite data/ 2>/dev/null || true",
|
||
]
|
||
|
||
[images.api]
|
||
dockerfile = "infra/images/api.Dockerfile"
|
||
context = "."
|
||
path_filter = ["infra/images/api.Dockerfile", "src/**", "pyproject.toml"]
|
||
|
||
[images.llm]
|
||
dockerfile = "infra/images/llm.Dockerfile"
|
||
context = "."
|
||
path_filter = ["infra/images/llm.Dockerfile", "src/**", "pyproject.toml"]
|
||
|
||
[images.mc]
|
||
dockerfile = "infra/images/mc.Dockerfile"
|
||
context = "infra/rustfs/"
|
||
path_filter = ["infra/rustfs/**", "infra/images/mc.Dockerfile"]
|
||
scan = false
|