Files
stack/stack.toml
kert c1a199c878 fix(llm): thread-local bib Store; deterministic collapse; unique labels; era coverage; system prompt; prompt budget (refs #691 #692)
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.
2026-09-10 13:19:03 -04:00

321 lines
10 KiB
TOML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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