Spaces:
Paused
Paused
| # Architecture Decision Records β Auralynq | |
| Short ADRs. Each: **Context β Decision β Rationale β Alternatives rejected.** | |
| --- | |
| ## ADR-0001 β Product & package naming | |
| **Context.** The product must use one consistent name across package, CLI, services, UI, and docs. | |
| **Decision.** Product = **Auralynq**. Python package / modules / folders / CLI / services = `auralynq`. MCP server = `auralynq-mcp`. Container prefix = `auralynq-*`. Tagline = "Talk to Your Data". **PathRAG** is preserved only as the name of the graph-retrieval algorithm/paper. | |
| **Rationale.** Single source of truth prevents drift; a repo-wide name audit (`make name-audit`) enforces it. | |
| **Alternatives rejected.** Mixed/generic naming (e.g. a "Voice-RAG"-style label, or branding the product after the PathRAG algorithm) β causes branding drift and import confusion. | |
| --- | |
| ## ADR-0002 β Python 3.11 baseline (spec asked 3.12) | |
| **Context.** Spec requests Python 3.12; the build/CI environment ships Python 3.11.14. | |
| **Decision.** Target `requires-python = ">=3.11"`. Code is written to be 3.11/3.12 compatible; CI matrices both. | |
| **Rationale.** Use the interpreter actually present so `make setup`/tests run at $0 with no extra downloads. No 3.12-only syntax is used. | |
| **Alternatives rejected.** Forcing a 3.12 toolchain install β slower setup, no functional benefit for this codebase. | |
| --- | |
| ## ADR-0003 β Lightweight core + optional heavy extras + deterministic fallbacks | |
| **Context.** The spec mandates "runs locally at $0", "missing paid keys never break the demo", yet also wants bge-m3, Qdrant, whisper, pyannote, Kokoro, langgraph, ragas, etc. β multi-GB GPU stacks. | |
| **Decision.** Core deps are pure-Python/light (pydantic, fastapi, numpy, networkx, httpx). Every heavy/paid integration lives in a `pyproject` extra and is **imported lazily**. Each subsystem ships a deterministic, dependency-free **fallback**: | |
| - Embeddings β hashing-bag embedder (stable, seeded) when bge-m3 absent. | |
| - Vector store β in-process `MemoryStore` when `qdrant-client`/server absent. | |
| - LLM β extractive synthesizer (citation-faithful) when Ollama/keys absent. | |
| - ASR/TTS β null/sine + transcript-passthrough providers when models absent. | |
| - Agent β native Python state machine mirroring the LangGraph graph when `langgraph` absent. | |
| - Eval β numpy metric implementations when `ragas`/`jiwer` absent. | |
| **Rationale.** The whole platform β ingest β index β retrieve β agent β serve β eval β MCP β UI β is exercisable and unit-tested offline. Upgrading to real models is a config flag, not a code change. This is the single most important design choice for reproducibility. | |
| **Alternatives rejected.** Hard-requiring the ML stack β breaks `$0` guarantee, makes CI heavy/flaky, slows onboarding. | |
| --- | |
| ## ADR-0004 β Provider abstraction via factories + `auto` resolution | |
| **Context.** Many swappable backends (embeddings, LLM, rerank, ASR, TTS, vector). | |
| **Decision.** Each subsystem exposes a `Protocol`/ABC and a `factory.py` that resolves `provider=auto` by probing env keys and importable packages, falling back deterministically. Selection is logged once at startup. | |
| **Rationale.** One config surface; testable resolution logic; honest "what's actually running" reporting at `/health`. | |
| **Alternatives rejected.** Hard-wired imports; conditional logic scattered across call-sites. | |
| --- | |
| ## ADR-0005 β Podman-first containerization | |
| **Context.** Target machine has no Docker; Podman 3.4 with `podman-compose` (the `podman compose` subcommand is absent). | |
| **Decision.** `scripts/check_container_runtime.sh` detects `podman compose` then `podman-compose`; all Makefile stack targets call it. Compose file is `compose.yml`. Images are rootless-friendly (no privileged ports, named volumes). | |
| **Rationale.** Matches the actual environment; degrades gracefully with a clear message if neither is present. | |
| **Alternatives rejected.** Docker / docker-compose β not installed, explicitly disallowed. | |
| --- | |
| ## ADR-0006 β PathRAG isolated, attributed, flow-pruned | |
| **Context.** Graph retrieval must implement PathRAG (Chen et al., 2025) with flow-based pruning. | |
| **Decision.** PathRAG lives only under `auralynq/retrieval/pathrag/`. We implement node retrieval β relational path expansion (bounded hops) β resource-flow-based path pruning β path-reliability scoring β deterministic path-to-text prompting with golden-region (most-reliable-at-edges) ordering. The algorithm is a clean-room implementation from the paper; attribution + license notes in `THIRD_PARTY.md`. | |
| **Rationale.** Clear module boundary, testable scoring, no licensing entanglement. | |
| **Alternatives rejected.** Vendoring a third-party repo wholesale (license/maintenance risk). | |
| --- | |
| ## ADR-0007 β Relational knowledge graph in NetworkX + SQLite-style JSON persistence | |
| **Context.** Need entities, relations, provenance, spans, timestamps, reliability β small/local. | |
| **Decision.** Build the KG with `networkx.MultiDiGraph`; persist as versioned JSON (`graph.json`) plus a node/edge provenance table. Reliability scores are computed from corroboration count + source trust. | |
| **Rationale.** Zero-infra, diff-able, fast for laptop-scale corpora; trivially swappable for a real graph DB later. | |
| **Alternatives rejected.** Neo4j (infra weight), pure in-memory (no persistence). | |
| --- | |
| ## ADR-0008 β Agent as explicit typed state machine | |
| **Context.** Spec lists 11 nodes with latency budgets and iteration caps. | |
| **Decision.** A single Pydantic `AgentState` flows through nodes. When `langgraph` is installed we compile a real `StateGraph`; otherwise an equivalent ordered executor runs the same node functions. Every node emits a trace span with timing; the trajectory is returned in the API response and rendered in the UI. | |
| **Rationale.** Identical behavior with/without langgraph; full observability; deterministic tests. | |
| **Alternatives rejected.** langgraph as a hard dependency (heavy, breaks $0 default). | |
| --- | |
| ## ADR-0009 β SSE for chat tokens, WebSocket for voice | |
| **Context.** Need token streaming and a bidirectional voice channel. | |
| **Decision.** Chat uses Server-Sent Events (`sse-starlette`) β simple, proxy-friendly, unidirectional. Voice uses a WebSocket carrying control + audio frames for the push-to-talk loop. | |
| **Rationale.** Right tool per channel; SSE avoids WS overhead for plain token streams. | |
| **Alternatives rejected.** WS for everything (more complex client), polling (latency). | |
| --- | |
| ## ADR-0010 β Eval harness owns all benchmark numbers | |
| **Context.** README must not contain invented numbers. | |
| **Decision.** Every metric in the README is produced by `make eval` / `make bench` and written under `reports/`. Unmeasured metrics are explicitly marked "pending". A frozen golden set + drift check guards regressions. | |
| **Rationale.** Honesty + reproducibility; the README is generated from real artifacts. | |
| **Alternatives rejected.** Hand-written marketing numbers. | |
| --- | |
| ## ADR-0011 β Optional bearer-token API auth (open-by-default) | |
| **Context.** The serving layer needs a production auth story, but the headline | |
| promise is a zero-friction local `$0` demo. These pull in opposite directions. | |
| **Decision.** Auth is gated by `AURALYNQ_SERVE__API_KEY`. Empty (default) β the API | |
| is open β the local demo and tests are unaffected. When set, an `AuthMiddleware` | |
| requires `Authorization: Bearer <key>` on all endpoints except `/health` and | |
| `/metrics` (and CORS pre-flight), returning a structured 401. Tokens are compared | |
| with `hmac.compare_digest` (constant-time). | |
| **Rationale.** One env var flips a local demo into an authenticated deployment with | |
| no code change; health/metrics stay public for orchestrators/probes; constant-time | |
| compare avoids timing leaks. | |
| **Alternatives rejected.** Always-on auth (breaks the $0 demo), per-route decorators | |
| (scattered, easy to miss an endpoint), full OAuth/JWT (over-engineered for a | |
| single-tenant local-first tool; can layer on later behind the same middleware). | |
| --- | |
| ## ADR-0012 β Deployment hardening for remote/server exposure | |
| **Context.** The stack is run on a remote server reachable by browsers on other | |
| machines, with all ports bound to `0.0.0.0`. The earlier setup ran api/worker as | |
| container-root, exposed Qdrant + Phoenix publicly with no auth, had no restart | |
| policies/healthchecks, and (for rootless DNS reasons) reached Qdrant via the host | |
| gateway. Enabling API auth naively would either break the browser UI or leak the | |
| key into the client bundle. | |
| **Decision.** | |
| - **Network surface:** only the **web UI** and **API** publish on `0.0.0.0`. | |
| **Qdrant and Phoenix** bind to `${AURALYNQ_BIND_INTERNAL:-127.0.0.1}` β on this | |
| host the podman bridge gateway (`10.88.0.1`/cni-podman0), reachable by containers | |
| (via `host.containers.internal`) and host tooling, but **not** from the external | |
| NIC. Default `127.0.0.1` keeps a clean host secure out of the box. | |
| - **API auth:** `AURALYNQ_SERVE__API_KEY` (empty == open). The browser never holds | |
| the key: the **web container runs a same-origin `/api/*` proxy** (Next.js route | |
| handler) that injects `Authorization: Bearer` server-side and streams responses. | |
| So `NEXT_PUBLIC_API_BASE=/api` β no IP and no secret are baked into the bundle. | |
| - **Least privilege:** api/worker drop the `user: "0:0"` override and run as the | |
| image's non-root `auralynq` (uid 10001); all services get | |
| `security_opt: ["no-new-privileges"]`, `restart: unless-stopped`, and | |
| healthchecks. Named volumes (`auralynq-data`, `auralynq-reports`) initialize with | |
| the image's non-root owner so writes succeed without host-ownership clashes. | |
| **Rationale.** Satisfies "expose only UI+API", "enable API auth", and "never expose | |
| secrets" simultaneously; the proxy also removes CORS as an attack surface (browser | |
| calls are same-origin). Restart + healthchecks give crash recovery. TLS / public | |
| reverse proxy is intentionally deferred to a separate step. | |
| **Alternatives rejected.** Baking the key into the browser bundle (leaks the secret); | |
| breaking the UI when auth is on (fails the "UI reachable" goal); binding internal | |
| services to `127.0.0.1` only (the rootless API can't reach host loopback β no working | |
| inter-container DNS on this Podman 3.4/CNI host); fixing DNS (dnsname/aardvark not | |
| available here). | |
| --- | |
| ## ADR-0013 β TLS reverse proxy (Caddy), single public HTTPS port | |
| **Context.** The hardened stack (ADR-0012) exposed plaintext HTTP on the API/UI | |
| ports. Production remote access needs TLS, ideally a single public entrypoint. | |
| **Decision.** Add a **Caddy** reverse-proxy service as the ONLY container published | |
| on `0.0.0.0` (default `:8443`). It terminates TLS and forwards to the web container | |
| (`auralynq-web:3000`), which serves the UI and proxies `/api/*` to the API. web, | |
| api, qdrant, phoenix only bind to `${AURALYNQ_BIND_INTERNAL:-127.0.0.1}` (loopback) | |
| plus the shared container network. TLS uses a **self-signed cert baked into the | |
| Caddy image** (`containers/caddy.Dockerfile`) with the server IP + localhost as | |
| SANs, served via an explicit `tls /certs/site.crt /certs/site.key` directive β | |
| reliable for an IP / no-domain host (Caddy's on-demand internal-CA issuance is | |
| unreliable for bare-IP sites). HTTP/3 is disabled (rootless can't size the QUIC | |
| UDP buffer). `flush_interval -1` keeps SSE streaming + WebSocket voice unbuffered. | |
| **Rationale.** One public TLS port; the browser only ever talks to Caddy; the API | |
| key never leaves the server (web `/api` proxy injects it). For a real domain, set | |
| `AURALYNQ_SITE_ADDRESS=https://your.domain` + `AURALYNQ_TLS=internal` (or ACME). | |
| **Alternatives rejected.** `tls internal` for a bare-IP site (handshake fails β no | |
| leaf cert for IP SNI); HTTP/3 (broken UDP buffer on rootless); terminating TLS in | |
| the app (re-implements a proxy, no cert automation). | |
| --- | |
| ## ADR-0014 β No-sudo fix for rootless container DNS (CNI 0.4.0 + dnsname) | |
| **Context.** This host runs **rootless Podman 3.4 with CNI**. Two defects broke | |
| inter-container networking: (1) podman generates network conflists with | |
| `cniVersion: 1.0.0`, but the installed **`firewall` CNI plugin only supports | |
| β€0.4.0**, so it fails validation and collapses the plugin chain; (2) the default | |
| `podman` network ships **without the `dnsname` plugin**, so there is no container | |
| DNS at all. Earlier ADR-0012 worked around this with brittle bridge-IP wiring and | |
| shared network namespaces. A proper fix via podman 4 / netavark needs root β but | |
| the whole point of rootless Podman is to avoid sudo. | |
| **Decision.** Fix it **without sudo** by rewriting the user-owned CNI conflists in | |
| `~/.config/cni/net.d/`: pin `cniVersion` to `0.4.0` (which `firewall`, `bridge` and | |
| `dnsname` all support) and append the `dnsname` plugin if absent. `scripts/stack_up.sh` | |
| does this idempotently before `up`. With DNS restored, all services share | |
| podman-compose's default network and address peers by **container_name** | |
| (`auralynq-qdrant` / `auralynq-api` / `auralynq-web`) β eliminating the bridge-IP | |
| guessing and shared-netns hacks. `dnsname` requires the `dnsmasq` binary (present) | |
| and the plugin binary at `/usr/lib/cni/dnsname` (present; the rootless plugin dir | |
| is set in `~/.config/containers/containers.conf`). | |
| **Rationale.** Pure user-space, persists across `down`/`up`, and restores the | |
| clean service-name networking Podman is supposed to provide. Supersedes the | |
| bridge-IP workaround noted in ADR-0012. | |
| **Alternatives rejected.** podman 4 + netavark (needs root); `external` networks in | |
| compose (podman-compose 1.5.0 ignores them β attaches to the default net anyway); | |
| shared network namespace for every service (couples lifecycles, can't publish | |
| per-container ports); service-name aliases (dnsname resolves container_name, not | |
| the compose service name, on this version). | |
| --- | |
| ## ADR-0015 β MCP as a first-class microservice (stdio + streamable-HTTP) | |
| **Context.** The MCP server exposes Auralynq's 7 tools but was stdio-only β usable | |
| by a local client that spawns the process, not by remote clients. The product goal | |
| is to "serve production-ready around the world" as composable microservices. | |
| **Decision.** Keep the transport-agnostic tool functions | |
| (`auralynq/mcp_server/tools.py`) as the single source of truth, and make the server | |
| **transport-selectable** via `--transport` / `AURALYNQ_MCP_TRANSPORT`: | |
| `stdio` (default, local), `streamable-http` (remote microservice on | |
| `AURALYNQ_MCP_HOST:PORT`, default `:8765`), or `sse` (legacy). A dedicated | |
| `auralynq-mcp` container in `compose.yml` runs the HTTP transport (internal-only; | |
| front with the Caddy TLS proxy for public exposure). An integration test drives | |
| the real stdio protocol end-to-end (client β server β tool) and auto-skips when | |
| the optional SDK is absent. | |
| **Rationale.** One tool implementation, many entry points (CLI, FastAPI, local | |
| MCP, remote MCP). HTTP transport lets agents/IDEs anywhere call the same grounded | |
| RAG + PathRAG + voice tools β the microservices foundation the roadmap needs β | |
| without duplicating logic. | |
| **Alternatives rejected.** A bespoke REST shim around the tools (re-implements MCP, | |
| loses client compatibility); HTTP-only (breaks the zero-config local Claude Desktop | |
| flow); embedding MCP in the FastAPI app (couples two servers with different | |
| lifecycles and auth models). | |
| --- | |
| ## ADR-0016 β Bearer-token auth for the MCP HTTP transports | |
| **Context.** ADR-0015 made MCP remotely callable over HTTP. An open HTTP MCP | |
| endpoint exposes the full ingest/search/agent toolset to anyone who can reach it β | |
| unacceptable for public deployment. | |
| **Decision.** Add an ASGI `MCPAuthMiddleware` wrapped around FastMCP's | |
| `streamable_http_app()` / `sse_app()` (served via uvicorn in `_serve_http`). It | |
| requires `Authorization: Bearer <key>` when a key is configured, returning a | |
| structured 401 otherwise; comparison is constant-time (`hmac.compare_digest`). | |
| Key resolution: `AURALYNQ_MCP_API_KEY` (dedicated) wins, else | |
| `AURALYNQ_SERVE__API_KEY` (reuse the HTTP API key). Empty β open. The **stdio | |
| transport is never gated** (it is local β the client spawns the process), so the | |
| zero-config Claude Desktop flow is unaffected. | |
| **Rationale.** Mirrors the HTTP API's auth model (ADR-0011) for consistency; one | |
| env var turns the remote MCP microservice from open to authenticated with no code | |
| change; constant-time compare avoids timing leaks. Combined with the Caddy TLS | |
| proxy (ADR-0013) the token travels encrypted. | |
| **Alternatives rejected.** OAuth/JWT (over-engineered for a single-tenant tool; | |
| can layer behind the same middleware later); gating stdio (breaks local clients); | |
| relying only on network isolation (defense-in-depth β auth + internal binding + | |
| TLS are complementary). | |
| --- | |
| ## ADR-0017 β Versioned container images + GHCR registry | |
| **Context.** "Serve production-ready around the world" needs deployable, pinnable, | |
| traceable images β not a single mutable `:latest`. The repo lives on GitHub. | |
| **Decision.** Publish three OCI-labelled images (`auralynq-api`, `auralynq-web`, | |
| `auralynq-caddy`) to **GitHub Container Registry** (`ghcr.io/<owner>/β¦`). The | |
| version is the single source `auralynq.__version__`; every build carries four | |
| tags: **`X.Y.Z`** (immutable release), **`X.Y`** (latest patch of a minor), | |
| **`<git-sha>`** (exact provenance), and **`latest`** (convenience). Tooling: | |
| `scripts/image_env.sh` (shared config), `scripts/build_images.sh`, | |
| `scripts/push_images.sh`, and `make images|push|version`. The authoritative | |
| publish path is CI: `.github/workflows/release.yml` builds + pushes on a `v*` git | |
| tag using the workflow's `GITHUB_TOKEN` (`packages: write`) β no manual login. | |
| `compose.yml` references `${AURALYNQ_IMAGE_PREFIX:-auralynq-}<svc>:${AURALYNQ_IMAGE_TAG:-X.Y.Z}` | |
| so it runs locally-built images by default and a pinned GHCR version when set. | |
| **Rationale.** Immutable + minor + sha + latest covers reproducible deploys, | |
| easy patching, exact provenance, and convenience. GHCR is zero-config from GitHub | |
| Actions. The single-source version + one tag scheme prevents drift; OCI labels | |
| make images self-describing (source, revision, license). | |
| **Alternatives rejected.** Bare `:latest` only (no rollback/pinning); Docker Hub | |
| (extra account/secret vs. GHCR's built-in token); a separate VERSION file (drifts | |
| from the package version); baking secrets into images (they stay in env/.env). | |
| --- | |
| ## ADR-0018 β Microservice split: one image, role entrypoints, k8s for scaling | |
| **Context.** The roadmap calls for "independently scalable microservices, served | |
| worldwide". The services already run as separate containers (api/mcp/worker/web + | |
| qdrant/phoenix/caddy), but podman-compose 1.5 on this host can't do per-service | |
| profiles or isolated subset deploys (verified: `up <svc>` pulls the whole | |
| depends_on graph; `COMPOSE_PROFILES` is ignored), and a single-host compose file | |
| isn't where independent autoscaling happens anyway. | |
| **Decision.** Split by **process/role over one shared image**, not by duplicated | |
| code or repos: `api`, `mcp`, and `worker` are the same `auralynq-api` image with | |
| different entrypoints (`uvicorn β¦app` / `python -m β¦mcp_server.server` / | |
| `python -m β¦serving.worker`); `web` is its own image. Ship **Kubernetes manifests** | |
| (`deploy/k8s`, kustomize) as the production scaling path: each service is its own | |
| Deployment+Service, api/mcp/web autoscale via HPA against a single Qdrant | |
| StatefulSet, the web UI is the only public surface (Ingress+TLS), and config/secrets | |
| are a ConfigMap+Secret. Compose remains the local/single-host mode. Topology + | |
| scaling rules are documented in `docs/SERVICES.md`. | |
| **Rationale.** One image = one build/test/provenance surface; roles diverge only | |
| by args + env. k8s is where "independently scalable" is real (per-service replicas, | |
| HPA, rolling updates), and the GHCR images (ADR-0017) make `kubectl apply -k` | |
| deployable anywhere. Stateless-against-Qdrant api/mcp scale freely; the one | |
| stateful tier (Qdrant) is isolated. Honest about the boundary: in-memory vector | |
| backend is NOT multi-replica-safe β k8s config defaults to the Qdrant backend. | |
| **Alternatives rejected.** Separate repos per service (duplicated core, version | |
| skew, against the "one repo, keep updating" goal); per-service images for | |
| api/mcp/worker (3Γ build/scan for identical code); compose profiles (broken on | |
| podman-compose 1.5); baking scaling into compose (single-host; no autoscaling). | |
| --- | |
| ## ADR-0019 β Optional Langfuse trace export (best-effort, never blocking) | |
| **Context.** The agent already builds an in-process `Trace` (per-node spans) and | |
| mirrors it to OpenTelemetry/Phoenix. Production observability for a service "served | |
| worldwide" benefits from a hosted trace/eval backend (Langfuse), but it must never | |
| become a hard dependency or a failure point on the answer path. | |
| **Decision.** Add `auralynq/telemetry/langfuse_export.py`: a best-effort exporter | |
| that pushes a finished `Trace` to Langfuse as a trace (question input, | |
| answer/route/metadata output) with one nested span per agent node. It is wired at | |
| the end of `answer_question` and `stream_answer_question` via a `_export_langfuse` | |
| helper that swallows all errors. Activation requires BOTH `LANGFUSE_PUBLIC_KEY` and | |
| `LANGFUSE_SECRET_KEY` AND the importable `langfuse` SDK (in the `telemetry` extra); | |
| otherwise it is a silent no-op. Host is configurable for self-hosted Langfuse; | |
| `/health` reports the active tracing backend. | |
| **Rationale.** Same provider-abstraction + offline-safe pattern as every other | |
| integration (ADR-0003/0004): one config flag turns it on, missing SDK/keys/network | |
| degrade to the local tracer, and observability can never break or slow an answer. | |
| Complements (does not replace) the in-process trace and Phoenix mirror. | |
| **Alternatives rejected.** Hard-requiring langfuse (breaks $0 default, heavy dep); | |
| exporting inline inside each node (couples the agent to a vendor, multiplies | |
| failure surface); replacing the in-process tracer (loses the UI trace panel + the | |
| always-on local record). | |
| ## ADR-0020 β Client-side API failover at the web proxy (local-primary, remote-backup) | |
| **Context.** Auralynq can run as a full stack on a laptop with all real provider | |
| keys, while a remote server (reached at `https://<server>:8443`) stays available | |
| as a backup. We want the laptop UI to keep answering when the laptop's own API | |
| container is down or wedged β without the user switching URLs or losing the | |
| same-origin/secret-injection model (ADR-0012). | |
| **Decision.** The Next.js `/api/[...path]` proxy gained an ordered-upstream | |
| failover policy (`web/lib/failover.ts`, pure + unit-tested): try the | |
| local/primary backend (`AURALYNQ_API_INTERNAL`) first; if it is unreachable | |
| (connection error or time-to-first-byte over `AURALYNQ_API_PRIMARY_TIMEOUT_MS`, | |
| default 12s) or returns a gateway-class status (502/503/504), replay the same | |
| request against `AURALYNQ_API_FALLBACK` (e.g. `https://<server>:8443/api`). When a | |
| fallback is configured the request body is buffered so it can be replayed; with no | |
| fallback the proxy streams the body untouched (unchanged behavior). The | |
| self-signed fallback cert is trusted via an `undici` `Agent` | |
| (`connect.rejectUnauthorized:false`) passed as the global-`fetch` dispatcher, | |
| enabled only when `AURALYNQ_API_FALLBACK_INSECURE_TLS=1`. The chosen upstream is | |
| reported in the `x-auralynq-upstream: primary|fallback|none` response header. | |
| **Rationale.** Failover belongs at the proxy: it already terminates the | |
| same-origin contract and holds the bearer token, so streaming (SSE) and auth keep | |
| working transparently. Only gateway-class statuses trigger failover β a 4xx/500 | |
| means the backend is up and answering, so failing over would mask real bugs. | |
| Bounding only time-to-first-byte (not the whole stream) lets long LLM responses | |
| flow while still escaping a hung backend. Empty `AURALYNQ_API_FALLBACK` is a | |
| no-op, so server deployments are unaffected. | |
| **Alternatives rejected.** DNS/round-robin or an external LB (heavier, loses the | |
| secret-injection proxy); client-side (browser) failover (would expose the backup | |
| URL + key to the browser, breaking ADR-0012); failing over on any 5xx (masks | |
| application errors); `NODE_TLS_REJECT_UNAUTHORIZED=0` (disables TLS verification | |
| process-wide instead of only for the one self-signed fallback). | |
| --- | |
| ## ADR-0021 β PPR-augmented PathRAG path scoring | |
| **Context.** The original PathRAG path scorer uses a resource-decay flow model: a budget | |
| is distributed from seed nodes with per-hop decay; a path's score is the bottleneck | |
| (minimum) edge flow. This correctly penalises long, low-reliability chains but has a | |
| structural bias toward short paths β a one-hop path from a seed always accrues the full | |
| initial resource regardless of how central its terminal node is in the broader graph. | |
| **Decision.** Augment the flow score with Personalised PageRank (PPR) terminal-node | |
| authority. `_assign_ppr()` builds a collapsed `DiGraph` (no multi-edges, weights = | |
| edge_weight Γ (0.1 + reliability)), runs `nx.pagerank()` with a personalisation vector | |
| concentrated on the seed entities (alpha = 0.15 teleport = 85% damping), then normalises | |
| scores to [0, 1]. The blended path score is **0.4 Γ flow_component + 0.6 Γ ppr_terminal**. | |
| Flow provides edge-level robustness; PPR provides convergent multi-hop authority. | |
| **Rationale.** PPR is the retrieval mechanism in HippoRAG (NeurIPS'24) and HippoRAG2, | |
| where it outperforms flow-only scoring on multi-hop benchmarks by 5β20 pp. Keeping the | |
| flow term (40 %) preserves the edge-reliability signal for short, high-precision paths | |
| while the PPR term (60 %) rewards nodes that aggregate evidence from many query-relevant | |
| directions. The `_apply_ppr()` call is a separate pass so the existing flow-based pruning | |
| (`_prune`) removes candidate paths first β PPR is only computed over the retained set. | |
| **Alternatives rejected.** Pure PPR without flow (loses per-edge reliability weighting); | |
| learning-to-rank path scoring (requires labelled training data, breaks $0 constraint); | |
| raising `max_hops` to compensate for flow's short-path bias (exponential expansion cost). | |
| --- | |
| ## ADR-0022 β Dual-signal evidence sufficiency (FAIR-RAG SEA) | |
| **Context.** `node_critic` used token-level overlap to decide whether to rewrite the query: | |
| `coverage = 1 - |gap_terms| / |q_terms|`. This correctly identifies missing vocabulary but | |
| produces false-positive rewrites when the query and documents use synonymous or paraphrased | |
| vocabulary β the retrieval is semantically sufficient even though the surface coverage is | |
| low. This caused unnecessary rewrite iterations and increased latency on paraphrase-heavy | |
| corpora (technical domains, multilingual documents). | |
| **Decision.** Add a second gate: `_semantic_coverage()` computes the cosine similarity | |
| between the query dense embedding and the mean of all retrieved-context dense embeddings, | |
| mapped from [β1, 1] to [0, 1]. A rewrite is only triggered when **both** | |
| `coverage < 0.6` and `semantic_coverage < 0.5`. A `semantic_coverage β₯ 0.5` reading | |
| means the retrieved passages are on-topic at the embedding level even if their surface | |
| tokens differ, so rewriting would likely recall the same passages with worse diversity. | |
| **Rationale.** Mirrors the Structured Evidence Assessment (SEA) module in FAIR-RAG | |
| (arXiv:2510.22344, Oct 2025), which found that combining lexical and semantic sufficiency | |
| signals reduces spurious rewrites by ~30 % on paraphrase test sets. The embedder is already | |
| loaded in `AgentDeps.hybrid.embedder` β no extra model weight. Falls back to 0.0 (always | |
| allows rewrite) for the offline hashing embedder, which does not produce meaningful cosine | |
| similarities. | |
| **Alternatives rejected.** LLM-as-judge sufficiency check (too slow, too expensive for | |
| the $0 constraint); BM25 IDF overlap (still purely lexical, same failure mode); ROUGE/BLEU | |
| (designed for generation, not retrieval sufficiency). | |
| --- | |
| ## ADR-0023 β Calibrated four-signal confidence scoring | |
| **Context.** The heuristic confidence formula `0.4 * context_count_fraction + 0.3 * | |
| has_citations + 0.3 * token_coverage_fraction` conflates three different phenomena: | |
| how many passages were retrieved (quantity), whether the LLM cited any of them (utilisation), | |
| and whether the question's vocabulary was covered (lexical breadth). A high score on all | |
| three is necessary but not sufficient β the retrieved passages could be marginally relevant | |
| (low score) yet plentiful enough to drive a high confidence score. | |
| **Decision.** Replace with four orthogonal signals: | |
| - **score_quality** = clip(mean_retrieval_score / 0.7, 0, 1). Reference 0.7 is the | |
| empirical centre of bge-m3 cross-encoder scores for on-topic passages (0.65β0.80 range). | |
| Scores below 0.3 indicate marginal relevance. | |
| - **citation_coverage** = len(cited_contexts) / len(all_contexts). Measures LLM utilisation: | |
| a low ratio means the LLM ignored most retrieved evidence, suggesting a poor fit. | |
| - **semantic_coverage** = cosine(query_emb, mean_ctx_embs) from `node_critic`. Grounding | |
| quality at the embedding level. | |
| - **token_coverage** = 1 β gap_fraction. Lexical breadth. | |
| Blended: `conf = 0.30 Γ sq + 0.30 Γ cc + 0.25 Γ sc + 0.15 Γ tc`. Weights were chosen so | |
| that retrieval quality and citation utilisation dominate (together 60 %), reflecting the | |
| Bayesian RAG finding (Frontiers 2026) that retrieval score distributions correlate Ο = 0.94 | |
| with precision at the answer level. | |
| **Rationale.** The new formula exposes *why* confidence is low via the `ConfidenceBar` | |
| UI component, which renders all four signals. This is a transparency improvement aligned | |
| with the UncertaintyRAG literature (arXiv 2025). The weights are empirically motivated but | |
| the formula is deterministic and does not require a held-out calibration set. | |
| **Alternatives rejected.** Monte-Carlo dropout or logit-based uncertainty (requires white-box | |
| LLM access, breaks the provider-agnostic design); Platt scaling (requires labelled | |
| confidence data); the existing heuristic (masked the retrieval quality signal). | |