Skip to content

Receipt knowledge graph (KAN-98)

The authenticated Ledger page /ledger/graph shows how receipt workers, OCR methods, model proposals, Brave searches and reviews contribute to a result. The default Processing story follows one item and collaboration run from OCR and receipt evidence through model expansion, Brave results and model verification to the final recommendation. Green borders highlight recorded supporting citations; expand alternatives to inspect other attempts. Item and run selectors keep older attempts separate. Completion timestamps select the latest run, including failures; older projections without timestamps explicitly report unknown completion order. The summary shows recorded token evidence, arithmetic checks, item duration and whether the canonical record changed. Model explanations remain reported claims.

Three additional perspectives share the same evidence: receipt lineage and collaboration, operational topology, and merchant/item/category relationships. Select a node for its evidence, usage, timing, citations and links; use Explore related receipts to investigate a worker, method, model, merchant or category.

Evidence and interpretation

Blue nodes describe processing or infrastructure, purple nodes describe methods and models, amber nodes describe observations/proposals/search results, and green nodes describe canonical records. Red indicates failures or decisions requiring review. A model recommendation never creates an accepted product or changes PostgreSQL. The receipt page displays the latest saved collaboration per line item, even when no accepted enrichment exists. Accept recommendation for this item applies the clean product name and URL after checking the receipt, current item fields and latest collaboration. It preserves the OCR item name, amounts and established categories; supported category rules resolve uncategorized items. The transaction records the user and old/new values in the description audit, marks the product enrichment accepted, updates the accepted search cache and appends a product_recommendation_accepted lineage event. Repeated clicks are idempotent; stale or ineligible recommendations cannot overwrite newer item descriptions.

The receipt-wide Apply accepted matches and category rules action repairs older uncategorized rows and fills empty products from accepted exact retailer/text cache matches after rechecking product-page evidence. Per-item collaboration reviews and existing descriptions are preserved. Each change records a receipt_item_refreshed lineage event, projected as an ItemRefresh with its audit record. After projection, an explicit ProductAcceptance connects the decision to the canonical item and its audit through ACCEPTED_AS, UPDATED and PERSISTED_AS. The original model payload remains unchanged. The graph connects each review to the proposals it considered, the observations it cites, and its guardrail/reconciliation decision. The decision properties explain disagreements, missing evidence, insufficient independent providers, truncated context and arithmetic failures.

OCR runs/passes and collaboration observations retain their run identities. Identical text from separate runs remains separate historical evidence. Search result title/snippet/scoring snapshots are specific to a collaboration run, while the referenced page URL connects runs. Image nodes contain hashes and references, never image binaries. RECEIVED links require recorded prompt source IDs or actual image exposure; stored evidence is not automatically presented as model input. Invalid citations are explicit unresolved/rejected links, not supporting facts. GPU summaries identify the sampled inference host/GPU, which may differ from the worker node. Missing GPU samples and historical provenance remain unknown.

The source schema is lineage.receipt_events, budget.receipt_ocr_runs, budget.receipt_ocr_passes, canonical expense/item records, and enrichment.receipt_collaborations/receipt_collaboration_runs. PostgreSQL is authoritative. New confirmed publications and worker attempt/outcome observations are persisted without any Neo4j call from the worker. Evidence persistence errors emit lineage_evidence_unavailable and do not turn successful receipt work into failure. Investigate these errors: the graph cannot reconstruct an event that was never retained. Existing OCR runs do not prove the identity of an original work message or a prior failed attempt; the projector does not invent those links.

Projection and replay

Neo4j Community 5.26.31 provides the derived graph. The visualization uses SVG and browser JavaScript inside the existing authenticated FastAPI application, without a third-party CDN or database credentials in the browser.

ReceiptFlowEntity nodes have a unique stable id, kind, display label and properties_json. Relationships use explicit types such as EXECUTED_BY, RUNS_ON, HAS_PASS, USES_MODEL, PRODUCED, CITES, SUPPORTS, CONSIDERED_PROPOSAL, RESULTED_IN, RECOMMENDS, RETRIED_AS, CONTAINS, CLASSIFIED_AS and PERSISTED_AS. Model IDs include provider and model name; and recorded digest where available; profile/context settings belong to individual invocations, so multiple Qwen profiles do not imply independent providers. Observation/candidate IDs include run and item identity. Processing attempt UUIDs distinguish redeliveries.

Each ReceiptSnapshot contains the atomic graph used by the UI and HAS_ENTITY membership for bounded cross-receipt navigation. The projector reads a repeatable PostgreSQL snapshot, then commits all graph changes in a Neo4j transaction. A receipt lock and source-snapshot timestamp reject older racing snapshots. Collaboration history streams one saved payload at a time within that same PostgreSQL transaction. The projector retains all graph history without buffering every copy of a long receipt's shared evidence in memory. Neo4j writes use bounded node and relationship batches inside the same atomic transaction. The projector has a 1 GiB memory ceiling for the complete snapshot of receipts with extensive collaboration history. Replaying the authoritative history merges stable entity/relationship IDs and does not duplicate them. Retained entity relationships can include past domain correlations; use the current ReceiptSnapshot.graph_json for current canonical classification. Canonical reprocessing can delete historical PostgreSQL rows; retention must preserve authoritative history for complete rebuilds. This slice does not invent history deleted before projection or provide an archival store.

The scheduled projector scans all receipts in batches every ten minutes and reports its last completed batch cursor. Repeated scans include new events for existing receipts. The next run retries after a backend failure; receipt workers and canonical persistence continue. Manual replay:

make receipts-graph DRY_RUN=1
make receipts-graph
make receipts-graph SHA=<64-character-source-sha>

For direct administration inside the application image, use home-budget-receipt-graph --all or --limit 100 --after <last-sha>. Avoid simultaneous manual runs while the scheduled job is active: stable IDs and source timestamps protect consistency, but extra scans waste resources.

GitOps deployment

The opt-in overlay is deploy/receipt-graph, built on the current production deploy/rabbitmq-private-telemetry overlay, preserving collectors and log delivery. It provisions the graph database on Arsene with 1Gi requested / 2Gi limited memory, a 1-CPU limit and an 8Gi local-path volume. The projector requests 100m CPU / 128Mi memory and has a 512Mi memory limit. These are initial budgets, not demonstrated performance limits; measure graph size and scan duration. The graph has no GPU reservation and must not crowd OCR or Ollama memory.

Create the uncommitted neo4j-secret through the approved secret workflow before selecting the overlay. make receipts-graph-secret creates the initial secret with a random password through a temporary owner-only file, prints no credentials, and preserves an existing secret. Store/recover the credential through the approved secret management process. Required keys:

Key Value
NEO4J_AUTH neo4j/<strong-password> for first database initialization
NEO4J_URI bolt://neo4j.home-budget.svc.cluster.local:7687
NEO4J_USER neo4j
NEO4J_PASSWORD The same password, supplied securely
NEO4J_DATABASE neo4j

Do not place these values in Git, paste them into chat or pass passwords through receipt/job logs. Neo4j initialization credentials do not rotate an existing database password. Community edition does not provide Enterprise fine-grained role separation; the server-side reader/projector share the managed account in this initial deployment. Restrict access with the network policy and fixed authenticated read endpoints. Use an appropriately licensed deployment with separate least-privilege credentials if stronger role separation is required.

kubectl kustomize deploy/receipt-graph
make receipts-graph-secret

In brownrook-infra/kubernetes/gitops/apps/ledger-app.yaml, change spec.source.path to deploy/receipt-graph, then merge that change. The brownrook-root parent owns the Ledger Application and reverts direct argocd app set overrides. After merging, reconcile the parent before syncing Ledger:

argocd app sync brownrook-root --server argocd.brownrook.net --grpc-web
argocd app get ledger --server argocd.brownrook.net --grpc-web
make deploy-k3s
kubectl --context brownrook-k3s1 -n home-budget rollout status statefulset/neo4j
make receipts-graph

Use the application image built from this change. The existing database bootstrap applies the additive lineage schema before new workers use it. Neo4j remains ClusterIP-only; its network policy allows Bolt only from Ledger and the projector. The web service account can list pods only in its own namespace. Existing OAuth proxy authentication protects graph APIs. No public Neo4j ingress is added.

Health, logs and troubleshooting

Worker health overlays use current Kubernetes Pod Ready conditions, refreshed every 30 seconds with a 90-second stale threshold. An absent historical pod has unknown current health, rather than being classified as failed. These overlays do not prove active receipt processing. Historical attempt/model outcomes and timings remain independently visible.

Optional GRAPH_PROMETHEUS_URL enables fixed Prometheus target-health queries where the server already has approved access. Prometheus on the monitoring host listens on loopback by default: do not expose it to the LAN just for this view. Missing metrics access is displayed as unavailable. Queue depth, processing latency and detailed metric panels remain accessible through Grafana; direct queue-depth/latency overlays are not part of this first canvas implementation.

Default Grafana links use https://grafana.idc.brownrook.net, Loki datasource UID cfy4qg8i178jkb, and Tempo UID home-budget-tempo. Override GRAPH_GRAFANA_URL, GRAPH_LOKI_UID, GRAPH_TEMPO_UID for another deployment. Receipt/run/worker identifiers filter Loki where available. Verify deployed links and backend retention: a recorded trace identifier does not guarantee a retained Tempo trace. No additional model/search calls happen when opening a view.

kubectl --context brownrook-k3s1 -n home-budget get cronjob receipt-graph
kubectl --context brownrook-k3s1 -n home-budget logs -l app=receipt-graph --tail=100
kubectl --context brownrook-k3s1 -n home-budget logs -l workload=receipt-worker --tail=100

A graph 503 leaves receipt processing available. Check Neo4j readiness, secret configuration, policy selectors and projector failures. A receipt missing from search may not have been projected yet. The UI returns at most 200 nodes per request and paginates; connected paths outside a page are not displayed until selected. This is a bounded neighborhood view, not a claim that no other evidence exists.

Investigations and recovery

Use Neo4j Browser through an authenticated administrative port-forward when needed. Do not expose database ports publicly. Example bounded queries:

// Worker/model/category impact: select an entity ID from the UI.
MATCH (s:ReceiptSnapshot)-[:HAS_ENTITY]->(n:ReceiptFlowEntity {id: $entity_id})
RETURN s.sha, s.name LIMIT 100;

// Evidence contributing to a decision.
MATCH p=(review:ReceiptFlowEntity)-[:INFORMS]->(decision:ReceiptFlowEntity)
WHERE decision.kind = 'Decision'
RETURN p LIMIT 100;

// Retry branches.
MATCH p=(a:ReceiptFlowEntity)-[:RETRIED_AS]->(b:ReceiptFlowEntity)
RETURN p LIMIT 100;

// Recommendations and review-required outcomes (details in properties_json).
MATCH (d:ReceiptFlowEntity {kind: 'Decision'})
RETURN d.label, d.properties_json LIMIT 100;

Neo4j is rebuildable only to the extent that PostgreSQL retains the source evidence. Back up PostgreSQL evidence with its existing procedures. For a graph backup, suspend the projector, stop Neo4j cleanly and snapshot its PVC using the cluster storage backup procedure; restore to a compatible pinned Neo4j version and verify authentication before resuming. A single local-path volume is not HA. For rebuilds, provision a fresh empty graph database and replay all authoritative receipts. Do not delete a production graph/PVC as a routine replay operation. Roll back to the previous Argo source path after stopping the projector and preserving its PVC/evidence; graph availability is not a prerequisite for receipts.

Completion evidence still required

KAN-98 remains In Progress until deployed successful, failed/retried, cache-only and collaboration demonstrations, real evidence-link checks, resource measurements, outage/catch-up and fresh-database replay are recorded. This implementation does not claim that current status overlays include every metric, that unavailable historical queue identities are reconstructed, or that a model recommendation is the accepted best product. The deployment commands are for the operator to run.

Product labels and OCR variants

For the receipt-page review and acceptance workflow, see Understand item readings and product recommendations.

Product names omit leading shopping prompts (Buy, Shop, Purchase, Order) and a matching retailer suffix such as | Sobeys Inc.. The original webpage title stays in the saved candidate and recommendation evidence. New acceptance saves the clean name; existing accepted raw titles display cleanly without silently rewriting canonical data or manual descriptions.

Receipt rows lead with the accepted interpretation when a product-based initialism correction has been accepted. The text selected during import stays in collapsed Import history. Before acceptance, the imported text remains the main label and any correction is labelled Proposed interpretation. Related OCR observations lists actual observations retained in that item's collaboration, grouped by reading with engine, pass, variant, run and original line text. Canonical text and model hypotheses are excluded from this list; a correction is never presented as a new OCR observation. Repeated observations remain inspectable without treating their count as independent agreement.

OCR text retrieval identifies related lines, not an exact canonical item row. The retained canonical items have no per-pass bounding box or source-line link; OCR output line numbers must not be interpreted as Ledger item numbers. This is especially ambiguous for duplicate descriptions and amounts. The receipt page and graph label these observations row association unverified, including old collaborations. New retrieval metadata and model prompts retain this uncertainty. Product-based corrections remain separate from observed OCR. Exact row attribution requires an explicitly retained item-to-source-position mapping; text similarity, matching amounts or pass line order alone do not establish it.

Queued model collaboration provenance

Graph projection version 7 retains the version 6 separation of independent and evidence-exchange rounds. CollaborationRound dispatches ModelTask entities with durable task/cohort IDs, provider queue, attempts and recorded timestamps. Tasks connect to their model invocations and recorded workers. Initial reviews, including abstentions, stay distinct from final reviews. An EvidenceExchange links shared initial findings to the final reviewers; final reviews REVISES or FOLLOWS their own initial review. Only final reviews inform the reconciled decision. Peer assertions never become OCR observations or independent evidence.

The Processing story shows independent work and shared review separately. Select a task or review to inspect its properties, citations and recorded relationships. Older runs are rebuilt from saved PostgreSQL task/result records. Historical exchange worker identity or exact prompt inclusion can remain unknown when it was not recorded; the projector does not infer it from another round. New worker results record task ID, start time, worker identity and exact exchange source IDs. Failed or expired tasks remain task failures rather than fabricated model calls.

Bounded web reads and projection version 7 (KAN-98)

A deployed Sobeys receipt detail request exhausted the ledger-web container's 512 MiB limit (OOMKilled, exit 137), causing repeated restarts and HTML 502 responses in place of JSON. Pagination had occurred only after loading and parsing the complete receipt snapshot, including every run's evidence.

Version 7 stores the exact serialized node on its receipt snapshot membership, a small selection index, and snapshot edge metadata in the same projection transaction. The web API loads bounded metadata first, selects a run or page, checks its serialized evidence size in Neo4j, then fetches only those nodes. It never reads the monolithic graph_json property. Existing normalized historical entities and exact per-node snapshot evidence remain available. The redundant monolithic graph_json property is removed on successful projection. A snapshot timestamp scopes all reads. A projection replacement returns HTTP 409 if the selection is no longer available rather than mixing evidence.

Interactive requests reject indexes larger than 15,000 nodes or 2 Mi characters and evidence selections larger than 2,000 nodes or 1 Mi characters with HTTP 413. These conservative limits bound individual requests; they are not measured whole-pod concurrency guarantees. Large evidence needs a more granular browser rather than increasing the limits without memory measurements. HTTP 503 indicates a legacy projection that must be replayed with the new application image. The UI reports non-JSON gateway/authentication responses explicitly.

After merging, passing CI (including the real Neo4j projection/read integration test), building the image, and deploying with make deploy-k3s, confirm the graph CronJob uses the new image. Wait for its next successful scheduled projection before loading the Sobeys graph. This rebuilds derived data without calling models or changing canonical receipts. Check Item/Run selectors, historical run isolation, web restart count and pod memory while viewing the receipt. These live checks remain required; the local fix alone does not complete KAN-98.

Projector memory follow-up

The first version-7 replay exceeded the projector's 1 GiB memory limit after its first 100-receipt batch (OOMKilled, exit 137). Web bounding alone was insufficient. The follow-up stops encoding/storing the legacy full-receipt graph_json copy, which the v7 web API no longer reads. Node and membership parameters stream in batches bounded by both 500 rows and 256 Ki characters of scalar text; an oversized individual entity is sent alone without losing evidence. The previous receipt graph is released before building the next receipt. Per-receipt start and completion logs identify an interrupted receipt and report graph node/edge counts. These changes reduce transient copies; the complete in-memory graph still must fit within the projector limit. Successful replay and live memory observation are required after deployment before declaring this incident resolved.

Rollbacks to web versions before v7 require reprojection with the matching old projector to regenerate graph_json. Do not roll back only the web image and assume that the old snapshot property still exists. Keep the authoritative evidence.

Select a collaboration before loading its index (projection version 8)

The version-7 replay succeeded, but the Sobeys receipt exceeded the full-receipt interactive index guard. Raising that guard would restore memory risk as history grows. Version 8 records per-node outgoing edge metadata and item/run association on exact snapshot memberships. Processing story reads only item/run selectors, chooses a run, and traverses that run's metadata in bounded steps. Shared OCR observations are not expanded into other runs. Producer and internal edge queries use snapshot edge IDs to exclude retained global historical relationships.

The selected run keeps the existing node, metadata and evidence limits. Whole receipt history no longer counts against the Processing story index limit. Other canvas perspectives still use the bounded full index and may report the existing limit for this unusually large receipt. They need separate graph-level pagination work; this change targets the item/run collaboration investigation.

Deploy the merged application and projector image, then complete a fresh replay before opening Processing story: version-7 snapshots explicitly request projection. Validate the Sobeys latest GPU run and an older CPU failure independently, and check that ledger-web does not restart. Do not close KAN-98 on unit tests alone.