Every command, response, and number on this page was captured from a real run against a local Ollama instance and the real Causeloop v2 API — nothing here is simulated. Token values are redacted; everything else is verbatim.
Concept: what “training” means in Causeloop
Causeloop v2 deliberately has no trainable model endpoint — there is no
/engine/model/train, no gradient descent, no model weights you fine-tune. If you’re looking for that, it doesn’t exist here on purpose (see Concepts → Provenance).- Ingest historical issues into the event log.
- Embed the corpus (Qwen embeddings, in this guide).
- Run the engine — theta calibration, Leiden graph partitioning, term naming, criticality scoring, and hazard (recurrence) fitting, all in one deterministic run.
- Review any abstentions — low-confidence extractions the model declined to guess on, routed to a human via the review queue.
- Activate — the resulting patterns, hazard fits, and predictions are live and queryable immediately; nothing further to “deploy.”
Prerequisites
Hardware notes
This guide was run on a single developer machine (no GPU cluster, no dedicated inference server) with Ollama serving both models. Two things to know before you start:- qwen3:4b “thinks” by default — and Causeloop turns it off. Qwen3’s chat template enables a hidden
<think>...</think>reasoning block before the visible answer, for every request. Measured on this machine: a 5-field JSON extraction burned ~1,850 completion tokens / ~44 seconds with thinking on, versus 103 tokens / 2.6 seconds with thinking off and grammar-constrained JSON — a ~17x difference. Causeloop’s Ollama provider disables thinking by default (OLLAMA_THINK=false); see Production notes for the full story and the quality tradeoff. - Embeddings are cheap.
qwen3-embedding:0.6breturns a 1024-dim vector in well under a second per call — the LLM calls (extraction, classification, narration) dominate the cost, not the embedding calls.
Step 1 — Configure the local Qwen profile
Start the backend withDATABASE_URL unset — this guide uses the in-memory store, which has no vector-dimension constraint. This matters: the repo’s pgvector column is fixed at vector(384) (db/migrations/0009_embeddings_pgvector.sql), but qwen3-embedding:0.6b returns 1024-dim vectors (verified below). Running against Postgres today would fail at the vector column; running in-memory sidesteps it entirely. See Production notes for the real options if you need Postgres persistence.
OLLAMA_THINK=false (disables qwen3’s hidden reasoning — the single biggest local-throughput lever, see Production notes), OLLAMA_TEMPERATURE=0.0 and OLLAMA_SEED=42 (deterministic decoding for a fixed prompt on a fixed model build).
Mint a scoped dev token (no external identity provider needed with AUTH_PROVIDER=none):
ollama is first in order and available: true — every real LLM call in this run went to qwen3:4b, with mock only as the never-triggered safety net.
Confirm the embedding dimension while you’re at it — this is the number that has to match EMBEDDINGS_DIM and (in a Postgres deployment) the pgvector column:
Step 2 — Convert and ingest the dataset
The source dataset isissues.xlsx — a Sheet1 with 250 rows and three columns: Issue ID, Unstructured Text, issue_created. Convert each row into the v2 ingest contract (external_id, title, narrative, occurred_at), following the same title convention as scripts/convert_issue_set_2.py — first sentence, truncated to 120 chars:
Idempotency-Key:
POST /ingest/batch itself only validates and persists — it does not call any model synchronously. Every engine-eligible issue (one with a non-empty narrative) then automatically kicks off, in the background:
- Forge — a 5C extraction call (
concern/context/consequence/cause/cap) that produces the text the pattern-clustering embedding is built from, plus the embedding call itself. - Conformal extraction — two classification calls (
process_cadence,customer_harm) that feed criticality/financials, each scoring every candidate label.
OLLAMA_THINK=false is the default, see Production notes).
Watching forge + extraction run
Each accepted issue schedules its forge and extraction jobs as fire-and-forget background tasks — the ingest response returns immediately. Poll an issue to watch itsconcern/cause/consequence fields populate as forge completes:
AI-AUDIT-001, narrative: “Just found out the dev team in London has been pasting snippets of our proprietary trading algorithms into public ChatGPT to debug them…”):
Step 3 — Run the engine (“train”)
Once forge has completed, trigger a full engine run:GET /v1/engine/runs/{run_id} until status is terminal. The run itself is fast — every LLM-heavy step already happened at ingest time, so the engine stages are pure deterministic computation over stored embeddings and events. Real result (250 issues, total stage time under one second):
partition_degenerate: false is the signal that the Qwen embeddings produced real cluster structure (a broken embedding path shows up here as “100% singletons”). Note criticality_rollup projecting all 37 loops onto pattern rows and hazard_fit fitting all 37 — both were broken before the fixes shipped with this guide (see bugs below).
Step 4 — Inference: read the trained model
The same run’s outputs are queryable immediately — “inference” here just means reading patterns, forecasts, and recommendations back out of the run you just calibrated.Patterns
Hazard forecast + counterfactual
events_avoided: 0.0 here is honest, not broken: the fitted criticality covariate (ci) is 0 for this pattern because criticality inputs are still sitting in the review queue (see Step 5) — a counterfactual that reduces a zero covariate changes nothing. Resolve the abstentions and re-run, and this number moves.
Predictions
GET /v1/predictions lists the per-pattern, per-horizon rows the run generated (37 patterns × horizons), each carrying p_recurrence, horizon_days, and full provenance back to the run.
Backtest + accuracy
GET /v1/predictions/accuracy now reports status: "ok" with these figures instead of no_backtest_yet — accuracy is only ever computed from backtests, never fabricated. (The 0.0 precision/recall on a 14-day horizon over synthetic-history-free data is itself honest: the interval coverage of 0.75 and the calibration curve are the informative figures at this data volume.)
Recommendation + business case
business_case is computed server-side only from the pattern’s hazard fit, its counterfactual, and the active rate-card — it is rejected with a 422 if a client tries to supply it. Real response:
events_avoided_365: 0.0 because the criticality covariate this fix-class would reduce is still zero pending review-queue resolution — the business case is coupled (by hash) to the exact hazard run and counterfactual that produced it, and it improves as the calibration data does.
Step 5 — Review queue: what Qwen abstained on
Causeloop never guesses on a low-confidence classification — a multi-label or empty conformal prediction set writesvalue: null and creates a review-queue item instead (docs/v2/DESIGN.md Design Rule 4: abstention is a valid answer). This is real, not simulated: qwen3:4b abstained on real issues in this run.
Real numbers from this run:
tau = 0.50), so any label scoring ≥ 0.5 enters the prediction set — and a set with two labels (genuine ambiguity) or zero labels (no signal) abstains to a human rather than guessing. 201 of 250 issues still got at least one attribute written directly (a confident singleton set); the rest of the attribute×issue pairs went to review. As humans resolve items, the calibration set grows and the threshold tightens to hit the target coverage (90%) empirically — abstention volume drops on real evidence, not on optimism.
Here’s a real item from this run — qwen3:4b scored a finding’s customer_harm and found genuine ambiguity between systemic (0.85) and material (0.75):
resolved, the value was written with origin: "human" (human always wins over the model), an attr_corrected event was appended, and the calibration set grew:
financial_loss_cents — that one involves no LLM at all (design rule: LLMs never produce dollars); a deterministic regex parses exactly one unambiguous monetary mention from the narrative, and zero or multiple mentions abstain to review.
Step 6 — Replay: prove the run is deterministic
Every engine run pins its inputs (config versions, embedding manifest, scope) into a manifest. Replaying a run re-executes from that exact manifest and diffs the result against the original — this is the determinism gate, and it’s a real re-execution, not a cached response.llm_cache), so it verifies the engine is deterministic given fixed inputs. Cross-run LLM determinism is a separate property — see Production notes.
Real bugs found and fixed while writing this guide
This guide is only useful if the commands on this page actually work as written. Running the flow for real surfaced six genuine bugs in the local-Qwen path, all fixed incauseloop-backend (branch feat/v2-pipeline) alongside this guide:
1. LLM_PROVIDER_ORDER=ollama silently resolved to nothing
1. LLM_PROVIDER_ORDER=ollama silently resolved to nothing
app/ai/router.py’s provider map only had anthropic, openai, and mock — there was no ollama entry, even though the capabilities layer (app/capabilities/registration.py) and its tests already assumed a provider literally named "ollama" existed. Setting LLM_PROVIDER_ORDER=ollama silently fell through to the mock safety net with no error. Fixed by adding app/ai/providers/ollama_provider.py — a native /api/chat client with think: false, grammar-constrained JSON output (format: "json" whenever the request carries a JSON schema), and seeded temperature-0 decoding — registered under "ollama" in the router, with new OLLAMA_BASE_URL/OLLAMA_MODEL/OLLAMA_THINK/OLLAMA_TEMPERATURE/OLLAMA_SEED settings.2. Sovereign-mode capability routing was completely broken
2. Sovereign-mode capability routing was completely broken
app/capabilities/resolver.py looked up a purpose’s provider chain with binding.bindings.get(str(purpose)) — but Purpose is a (str, Enum), and under Python’s Enum semantics str(Purpose.EXTRACTION) returns "Purpose.EXTRACTION", not "extraction". Every purpose lookup (extraction, naming, fishbone, recommendation, prediction) missed the YAML binding and raised LookupError, which every call site treats as “no provider resolvable” and silently abstains on. This affected every sovereign/hybrid-mode deployment, not just this guide’s Ollama profile — confirmed by a 250-issue run that abstained on 100% of extractions before the fix. Fixed with a small _binding_key() helper that reads .value off an Enum member.3. Structured extraction prompts didn't budget for thinking-mode tokens
3. Structured extraction prompts didn't budget for thinking-mode tokens
Four call sites (
conformal_extract.py, extract.py, rootcause/classify.py, rootcause/narrate.py) set max_tokens between 160 and 512 for short JSON/classification prompts — reasonable for a non-reasoning hosted model, but with thinking enabled qwen3:4b’s <think>...</think> reasoning consumed the entire budget before ever emitting an answer, leaving resp.text == "" and indistinguishable from genuine model abstention. Measured directly: a 5-field extraction needed ~1,850 completion tokens; a 4-label classification needed more than 2,048. Budgets were raised to 3072/2048 as defense-in-depth for thinking-enabled configurations (the default OLLAMA_THINK=false path answers in ~100 tokens regardless).4. Batch ingest never forged (embedded) issues at all
4. Batch ingest never forged (embedded) issues at all
POST /ingest/batch intentionally skips synchronous per-record encoding for throughput (documented in the router as “deferred to Phase 2”), but nothing else ever picked it up — app/services/engine_run.py’s real_feature_forge only reads already-stored embedding vectors, it never computes any. Every batch-ingested workspace therefore reached the engine with zero embeddings. Confirmed against a full 250-issue batch: the engine run reported "degenerate partition detected (100% singletons)" and produced 0 patterns, in ~16 seconds — far too fast for any real model calls to have happened. Fixed by firing the same forge job batch ingest already fires for extraction, mirroring the existing _trigger_auto_extract fire-and-forget pattern.5. A 250-record burst pool-timed-out ~125 LLM calls — and poisoned the cache with the failures
5. A 250-record burst pool-timed-out ~125 LLM calls — and poisoned the cache with the failures
Batch ingest fires forge + extraction jobs for every record concurrently, so ~250 first LLM calls hit the provider’s single
httpx.AsyncClient at once. httpx’s default connection-pool acquire timeout is 5 seconds; ~125 calls raised PoolTimeout in the opening seconds — and PoolTimeout stringifies to an empty string, so the log line read provider call failed: with no reason. Worse: conformal_extract cached the resulting all-zero score dict into llm_cache, keyed by (model, prompt, narrative) — recording a transport failure as “the model confidently scored every label 0.0”, permanently. Fixed with a client-side asyncio.Semaphore(4) (Ollama serializes across a handful of slots anyway; held requests wait in-process with no socket and no ticking timeout), the pool-acquire timer disabled, %r logging so empty-string exceptions stay identifiable, and — separately — failed scorings are no longer cached. Verified: the same 250-record burst re-run produced 0 provider failures.6. Engine runs never projected patterns — the whole inference surface stayed empty
6. Engine runs never projected patterns — the whole inference surface stayed empty
POST /engine/runs is documented as the single entry point that “executes the whole pipeline”, but the run DAG’s criticality_rollup stage was a no-op deferring to “the wave that owns pattern projection” — which never landed. Only the Redis-gated pipeline orchestrator’s S3 stage projected loops→patterns, and the auto-infer path is gated on a trained workspace model. Confirmed live: a run over 250 real issues succeeded with loops=42 — and hazard_fit fitted 0 patterns (it fits per-pattern), GET /patterns returned [], and predictions/recommendations/financials all stayed empty. Fixed by implementing real_criticality_rollup: find-or-create a pattern row per loop keyed by fingerprint.loop_id (the same semantics as the pipeline’s S3 stage, so both entry points converge on the same rows), link member issues, name from taxonomy/top-terms, then roll up criticality per pattern. The run output above — 37 patterns projected, 37 fitted — is this fix working.Troubleshooting
Symptoms → causes, distilled from the real debugging that produced this guide:| Symptom | Likely cause | Check / fix |
|---|---|---|
Engine run succeeds but GET /patterns is [] | Running a backend without the pattern-projection fix (bug 6), or issues were never embedded (bug 4) | criticality_rollup counters in stages[] should show patterns_projected > 0 |
Run reports partition_degenerate: true / “100% singletons” | Issues reached the engine with no (or all-identical) embeddings | Confirm forge ran: issue detail should show concern/content_hash or 5c_abstained:* in derived_flags |
Every extraction abstains; log says provider call failed: (blank reason) | Transport failure whose exception stringifies to empty — pool/read timeout under burst load | Blank-reason lines are timeouts, not model output; bound client concurrency (fixed in bug 5) |
| Every LLM call answers instantly but output is canned/mock-looking | LLM_PROVIDER_ORDER didn’t resolve a real provider and fell through to mock | GET /v1/ai/providers — the first entry in order must be ollama with available: true |
LLM calls return empty text with finish_reason: length | Thinking-mode tokens ate the whole max_tokens budget | Keep OLLAMA_THINK=false; if you enable it, budgets must be ~2,000+ tokens for short JSON tasks |
| Calls take 40-50s each instead of 2-5s | qwen3 thinking mode is on (or you’re on the OpenAI-compat endpoint, which can’t turn it off) | OLLAMA_THINK=false via the native-API provider; verify with a single timed call |
Some issues never show concern after forge “completes” | Not stuck — the 5C extraction honestly abstained on that narrative | Look for 5c_abstained:concern in the issue’s derived_flags; the issue still embedded and clusters |
| Hundreds of review-queue items right after first ingest | Conformal cold-start (no calibration items yet ⇒ documented default threshold) — expected | Resolve items; GET /ai/calibration/{task} shows the set growing and coverage tracking begin |
| Server aborts at startup complaining about identity provider or memory store | A repo .env pinning ENVIRONMENT=production is shadowing your profile | Export ENVIRONMENT=development AUTH_PROVIDER=none in the launch env (process env beats .env) |
Production notes
Postgres + pgvector dimension. The repo’s pgvector column isvector(384) (db/migrations/0009_embeddings_pgvector.sql), sized for the default TEI model (BAAI/bge-small-en-v1.5, 384-dim). qwen3-embedding:0.6b returns 1024-dim vectors — that will not fit today. Two honest paths: (a) write an additive migration to a dimension-flexible embedding store if your schema can support it cleanly, or (b) run local-Qwen workflows against the in-memory store (as this guide does) and reserve Postgres persistence for a TEI-compatible 384-dim embedding model. Do not silently truncate or pad vectors to force a fit — that corrupts every downstream similarity computation.
TEI vs. Ollama for embeddings. HuggingFace TEI is the other supported embedding provider (EMBEDDINGS_PROVIDER=tei). TEI is purpose-built for batched embedding throughput and is the better choice for production ingestion volume; Ollama’s embedding endpoint is convenient for local development but was not designed for high-throughput batch encoding.
Model size tradeoffs. This guide uses qwen3:4b for every task tier (reasoning/balanced/fast) — simplest to reason about for a single-model local profile. On constrained hardware, qwen3:1.7b (set OLLAMA_MODEL=qwen3:1.7b) is meaningfully faster and lighter (~1.4GB vs ~3.5GB resident) with a real quality tradeoff on harder extraction/classification tasks; qwen3:0.6b exists but is a stretch for structured extraction. Benchmark against your own data before committing — with thinking already disabled, model size is the second lever, not the first.
The thinking-mode tax — the single biggest local-throughput lever. qwen3-family models emit a hidden <think>...</think> reasoning chain by default on every request, including 5-line JSON extractions. Measured on this machine (qwen3:4b, Ollama 0.23.2): with thinking on, one extraction = ~1,850 completion tokens / ~44s; with think: false plus format: "json" (grammar-constrained decoding), the same extraction = 103 tokens / 2.6s — a ~17x speedup, and the difference between a ~9-hour and a ~25-minute full-dataset onboarding. Three operational facts worth knowing, all verified directly:
- The
thinkfield only works on Ollama’s native/api/chatendpoint — the OpenAI-compatible/v1/chat/completionslayer silently ignores it, and qwen3:4b ignored the/no_thinkprompt-suffix soft switch entirely. This is why Causeloop’s Ollama provider uses the native API. think: falsealone is not enough for structured tasks: qwen3:4b still narrates its chain-of-thought in plain content before the JSON.format: "json"constrains generation from the first token and eliminates it.- The tradeoff is real but small for closed-vocabulary tasks: thinking mainly buys accuracy on open-ended reasoning (root-cause synthesis, narration), not on “score these 4 labels” prompts. Causeloop defaults to
OLLAMA_THINK=falseglobally; flip it totrueif you observe quality problems on reasoning-tier tasks and can afford the latency.
temperature: 0 and seed: 42 (OLLAMA_TEMPERATURE/OLLAMA_SEED), so decoding is deterministic for a fixed prompt on a fixed model build — this makes the seed_supported=True flag in app/capabilities/registration.py true in practice. Two honest caveats: (a) determinism holds per model build — pulling a new qwen3:4b blob or changing Ollama’s numeric backend can change outputs for the same seed; (b) the replay determinism gate above does not depend on LLM decoding determinism at all — it re-executes from the run’s pinned manifest, and extraction outputs are content-hash-cached in the llm_cache table (app/engine/forge/conformal_extract.py), so replays read the original run’s frozen outputs rather than re-rolling the model.
Related
Concepts
Provenance, abstention, hazard models, and configs — the vocabulary this guide assumes.
Onboarding Tutorial
The UI-driven onboarding wizard that wraps this same lifecycle for a new customer workspace.
Local Development
General local setup, without the Qwen-specific profile.
API Reference
Full endpoint reference for every call made in this guide.