Compare commits
127
Commits
125e1c80b9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb4308d8fd | ||
|
|
95f155b017 | ||
|
|
0e530f4436 | ||
|
|
c0a55b1182 | ||
|
|
df2a9d1f77 | ||
|
|
857ca4c971 | ||
|
|
262a77a52a | ||
|
|
22c0b42e96 | ||
|
|
5d3996b51d | ||
|
|
916923440a | ||
|
|
b314ed1c94 | ||
|
|
a21a34b057 | ||
|
|
6847bbc180 | ||
|
|
1263720497 | ||
|
|
fa4e1b4e16 | ||
|
|
1a3a82aedb | ||
|
|
d6d2fdf81f | ||
|
|
afdeca80d9 | ||
|
|
7d6636b33e | ||
|
|
b0c8a3f227 | ||
|
|
e8cf113af8 | ||
|
|
f9e72412c1 | ||
|
|
09299a27b7 | ||
|
|
89ca72d471 | ||
|
|
277884b5eb | ||
|
|
3a2823c02f | ||
|
|
2a974d6dea | ||
|
|
b03521a506 | ||
|
|
b3ad14832d | ||
|
|
c164a3da96 | ||
|
|
6507dff26d | ||
|
|
c522acec8b | ||
|
|
4fd560cad6 | ||
|
|
c48b58ff8d | ||
|
|
77a3dcf266 | ||
|
|
54b2e7db76 | ||
|
|
09904d5222 | ||
|
|
ee2a40c505 | ||
|
|
55b8c76a8c | ||
|
|
eb0dfdff04 | ||
|
|
0d4f033b34 | ||
|
|
ca142b9271 | ||
|
|
7bb432f6c8 | ||
|
|
b216b7e5b6 | ||
|
|
356d76b4b0 | ||
|
|
cca32344f3 | ||
|
|
434f466288 | ||
|
|
0331288641 | ||
|
|
3263254f1c | ||
|
|
997527be4d | ||
|
|
7f2f112e6a | ||
|
|
5687ec65c2 | ||
|
|
a2fa9a2ce7 | ||
|
|
68d9e554b0 | ||
|
|
5a3364f539 | ||
|
|
247e36e687 | ||
|
|
54a05d9311 | ||
|
|
a59ef5b930 | ||
|
|
814197cfd3 | ||
|
|
6c93a38093 | ||
|
|
f971e64745 | ||
|
|
db2e57402e | ||
|
|
5d49967833 | ||
|
|
08a086779b | ||
|
|
7e6431e4fd | ||
|
|
ce940f4a14 | ||
|
|
186d209aae | ||
|
|
e7fc63b216 | ||
|
|
4da32ad102 | ||
|
|
84a32f0d29 | ||
|
|
0fa42a0cc3 | ||
|
|
61c7b55e48 | ||
|
|
b13b5cd7a1 | ||
|
|
87cbd10090 | ||
|
|
67d1957b87 | ||
|
|
94fc3b67ec | ||
|
|
154a72ea5e | ||
|
|
e9822f025d | ||
|
|
9ce067bd9d | ||
|
|
2c71d1c637 | ||
|
|
6c02c905c8 | ||
|
|
b2e45f7f26 | ||
|
|
740eb0d00c | ||
|
|
511083ae40 | ||
|
|
c5c31b6ca6 | ||
|
|
e4ee0029c0 | ||
|
|
0efc7363c5 | ||
|
|
05273ac06b | ||
|
|
848cbf5fef | ||
|
|
622e124b8f | ||
|
|
ec1d031996 | ||
|
|
3dd7d9fe30 | ||
|
|
8278eb9a87 | ||
|
|
c41698acae | ||
|
|
c7e3124208 | ||
|
|
e30da6e5aa | ||
|
|
97dd3f826d | ||
|
|
bc9ab93afe | ||
|
|
6392772f41 | ||
|
|
b199093d1f | ||
|
|
be65f85f17 | ||
|
|
57d0df125d | ||
|
|
7f42089521 | ||
|
|
a730fc2016 | ||
|
|
06e71520c4 | ||
|
|
31b408882b | ||
|
|
a97881d80c | ||
|
|
3968ec8a7b | ||
|
|
9588bd82ae | ||
|
|
0d1553ca88 | ||
|
|
166470f532 | ||
|
|
c1d96b7b60 | ||
|
|
a7620c8b6f | ||
|
|
71b35fb85e | ||
|
|
f1c188323c | ||
|
|
afbb506dbc | ||
|
|
2a6234ff82 | ||
|
|
ad1670d36a | ||
|
|
fa56134b90 | ||
|
|
8f4c16fab1 | ||
|
|
56844c3f31 | ||
|
|
fb08232f58 | ||
|
|
0f79bce948 | ||
|
|
1ec85b0a16 | ||
|
|
0d18ffa780 | ||
|
|
423a3817c5 | ||
|
|
6af0520ed2 |
@@ -0,0 +1,37 @@
|
|||||||
|
# Match what `go build` actually needs; everything else bloats the
|
||||||
|
# build context and slows COPY . . to a crawl.
|
||||||
|
|
||||||
|
bin/
|
||||||
|
out/
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Reality test outputs — regenerable, not needed in the image.
|
||||||
|
reports/
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Per-run smoke logs in /tmp obviously don't end up here, but defensive.
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Local config + secrets — must NEVER ship in an image.
|
||||||
|
lakehouse.toml
|
||||||
|
secrets-go.toml
|
||||||
|
auth.env
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Editor swap files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Existing systemd unit files don't belong in the container — the
|
||||||
|
# image deploys via docker-compose, not systemd-inside-container.
|
||||||
|
deploy/systemd/
|
||||||
|
|
||||||
|
# Reality-test JSON corpora are large + the multi_coord_stress
|
||||||
|
# binary will reference them as files, but they get mounted at
|
||||||
|
# runtime, not baked in.
|
||||||
|
tests/reality/contracts/*.json
|
||||||
@@ -39,10 +39,15 @@ vendor/
|
|||||||
# Use /reports/* + un-ignore so git can traverse into reports/.
|
# Use /reports/* + un-ignore so git can traverse into reports/.
|
||||||
/reports/*
|
/reports/*
|
||||||
!/reports/scrum/
|
!/reports/scrum/
|
||||||
|
!/reports/reality-tests/
|
||||||
|
!/reports/cutover/
|
||||||
# Inside the audit directory, the per-run _evidence/ dump (smoke logs,
|
# Inside the audit directory, the per-run _evidence/ dump (smoke logs,
|
||||||
# command output) IS runtime — track the dir, ignore its contents.
|
# command output) IS runtime — track the dir, ignore its contents.
|
||||||
/reports/scrum/_evidence/*
|
/reports/scrum/_evidence/*
|
||||||
!/reports/scrum/_evidence/.gitkeep
|
!/reports/scrum/_evidence/.gitkeep
|
||||||
|
# Reality-test JSON evidence is runtime — track the dir + MD reports
|
||||||
|
# (committed deliberately as outcome record), ignore per-run JSON.
|
||||||
|
/reports/reality-tests/*.json
|
||||||
|
|
||||||
# Proof harness runtime output — same pattern as reports/scrum/_evidence.
|
# Proof harness runtime output — same pattern as reports/scrum/_evidence.
|
||||||
# Track the directory but ignore per-run subdirs.
|
# Track the directory but ignore per-run subdirs.
|
||||||
|
|||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
# syntax=docker/dockerfile:1.6
|
||||||
|
#
|
||||||
|
# Multi-stage Dockerfile for Lakehouse-Go.
|
||||||
|
#
|
||||||
|
# Single image carries all 11 daemon binaries; docker-compose runs
|
||||||
|
# one container per daemon (matches the systemd unit topology in
|
||||||
|
# deploy/systemd/). Operators can also `docker run lakehouse-go
|
||||||
|
# /usr/local/bin/lakehouse/<daemon>` to invoke any one daemon
|
||||||
|
# directly.
|
||||||
|
#
|
||||||
|
# Builder uses golang:1.25-bookworm (DuckDB cgo needs gcc + glibc;
|
||||||
|
# alpine's musl doesn't link the official duckdb-go bindings cleanly).
|
||||||
|
# Runtime is debian:bookworm-slim — same libc, much smaller surface.
|
||||||
|
#
|
||||||
|
# Build:
|
||||||
|
# docker build -t lakehouse-go:latest .
|
||||||
|
# Or with a tag:
|
||||||
|
# docker build -t lakehouse-go:$(git rev-parse --short HEAD) .
|
||||||
|
|
||||||
|
# ── Stage 1: builder ────────────────────────────────────────────
|
||||||
|
FROM golang:1.25-bookworm AS builder
|
||||||
|
|
||||||
|
# build-essential pulls gcc + make + libc-dev — DuckDB cgo needs all three.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy go.mod + go.sum first so module download is cacheable across
|
||||||
|
# source-only changes.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Source.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build all 11 daemon binaries + the staffing_workers script (used
|
||||||
|
# by the multi_coord_stress harness; ships in the same image so
|
||||||
|
# operators can run reality tests against a deployed stack).
|
||||||
|
RUN go build -trimpath -o /out/ \
|
||||||
|
./cmd/storaged ./cmd/catalogd ./cmd/ingestd ./cmd/queryd \
|
||||||
|
./cmd/embedd ./cmd/vectord ./cmd/pathwayd ./cmd/observerd \
|
||||||
|
./cmd/matrixd ./cmd/gateway ./cmd/chatd \
|
||||||
|
./scripts/staffing_workers ./scripts/playbook_lift ./scripts/multi_coord_stress
|
||||||
|
|
||||||
|
# ── Stage 2: runtime ────────────────────────────────────────────
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# CA certs for outbound HTTPS (Ollama Cloud, OpenRouter, OpenCode,
|
||||||
|
# Kimi). curl + jq for in-container health checks + smoke probes.
|
||||||
|
# tini handles PID 1 signal forwarding so docker stop sends SIGTERM
|
||||||
|
# to the actual daemon, not just to a wrapper.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
jq \
|
||||||
|
tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Non-root runtime user — same name as the systemd User= directive
|
||||||
|
# in deploy/systemd/, so file ownership stays consistent across
|
||||||
|
# deployment modes (docker-compose vs systemd).
|
||||||
|
RUN groupadd --system --gid 999 lakehouse \
|
||||||
|
&& useradd --system --uid 999 --gid 999 \
|
||||||
|
--no-create-home --shell /usr/sbin/nologin lakehouse
|
||||||
|
|
||||||
|
# Layout matches /usr/local/bin/lakehouse/<daemon> from REPLICATION.md
|
||||||
|
# so docs apply equally to systemd + docker deployments.
|
||||||
|
COPY --from=builder /out/* /usr/local/bin/lakehouse/
|
||||||
|
|
||||||
|
# /var/lib/lakehouse for pathway/observer JSONLs; /var/log/lakehouse
|
||||||
|
# in case operators want file logs in addition to docker logs.
|
||||||
|
RUN mkdir -p /var/lib/lakehouse/pathway /var/lib/lakehouse/observer /var/log/lakehouse \
|
||||||
|
&& chown -R lakehouse:lakehouse /var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
USER lakehouse
|
||||||
|
WORKDIR /var/lib/lakehouse
|
||||||
|
|
||||||
|
# No default CMD — operators (or docker-compose) MUST specify which
|
||||||
|
# daemon. Forces explicit topology rather than implicit "run
|
||||||
|
# everything in one container."
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
|
||||||
|
# Default healthcheck targets gateway's port. Per-service compose
|
||||||
|
# overrides land per their own port.
|
||||||
|
HEALTHCHECK --interval=10s --timeout=2s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -sSf http://127.0.0.1:3110/health || exit 1
|
||||||
@@ -38,6 +38,28 @@ real-scale validation + G1/G1P/G2 pointer at the bottom).
|
|||||||
| `queryd` | 3214 | DuckDB SELECT over registered Parquets via httpfs |
|
| `queryd` | 3214 | DuckDB SELECT over registered Parquets via httpfs |
|
||||||
| `vectord` | 3215 | HNSW vector search (+ optional persistence to storaged) |
|
| `vectord` | 3215 | HNSW vector search (+ optional persistence to storaged) |
|
||||||
| `embedd` | 3216 | Text → vector via Ollama (default `nomic-embed-text` 768-d) |
|
| `embedd` | 3216 | Text → vector via Ollama (default `nomic-embed-text` 768-d) |
|
||||||
|
| `mcpd` | stdio | Model Context Protocol server (Claude Desktop / Code consumers) |
|
||||||
|
|
||||||
|
## MCP server
|
||||||
|
|
||||||
|
`bin/mcpd` exposes Lakehouse capabilities as MCP tools over stdio:
|
||||||
|
`list_datasets`, `get_manifest`, `query_sql`, `embed_text`, `search_vectors`.
|
||||||
|
All tools proxy to the gateway, so the gateway must be up first.
|
||||||
|
|
||||||
|
Wire into Claude Desktop / Claude Code by adding to the MCP config:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lakehouse": {
|
||||||
|
"command": "/path/to/golangLAKEHOUSE/bin/mcpd",
|
||||||
|
"args": ["--gateway", "http://127.0.0.1:3110"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replaces the Bun `mcp-server.ts` MCP-tool surface from the Rust system.
|
||||||
|
HTTP demo routes (the staffing co-pilot UI) stay Bun until G5.
|
||||||
|
|
||||||
## Acceptance smokes
|
## Acceptance smokes
|
||||||
|
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
# Lakehouse-Go — Replication Runbook
|
||||||
|
|
||||||
|
How to deploy Lakehouse-Go onto a fresh Linux host. Mirrors the layout
|
||||||
|
the dev box uses; covers prereqs, secrets, systemd units, validation.
|
||||||
|
|
||||||
|
## Prereqs
|
||||||
|
|
||||||
|
The host needs these external services reachable BEFORE the Lakehouse
|
||||||
|
daemons can usefully start. None are managed by Lakehouse-Go's own
|
||||||
|
units; they're operator infrastructure.
|
||||||
|
|
||||||
|
| Service | Purpose | Reachability |
|
||||||
|
|---|---|---|
|
||||||
|
| **Go 1.25+** | builds the binaries | `go version` returns ≥ 1.25 |
|
||||||
|
| **gcc** | DuckDB cgo (queryd) | `gcc --version` |
|
||||||
|
| **MinIO** (or AWS S3) | storaged backing store | `curl http://localhost:9000/minio/health/live` returns 200; bucket `lakehouse-go-primary` exists |
|
||||||
|
| **Ollama** | embedd + chatd LLM dispatch | `curl http://localhost:11434/api/tags` returns 200 with `nomic-embed-text-v2-moe` (or whatever `[embedd].default_model` names) loaded |
|
||||||
|
| **Langfuse** *(optional)* | trace + span observability | `curl http://localhost:3001/api/public/health` returns 200 |
|
||||||
|
| **PostgreSQL** *(optional)* | only if Langfuse is wanted | bundled with the Langfuse docker compose |
|
||||||
|
|
||||||
|
Bind ports the daemons use (G0 dev defaults; shifted by 10 from the
|
||||||
|
Rust legacy on 3100/3201–3204 so both stacks coexist):
|
||||||
|
|
||||||
|
| Daemon | Port |
|
||||||
|
|---|---:|
|
||||||
|
| gateway | 3110 |
|
||||||
|
| storaged | 3211 |
|
||||||
|
| catalogd | 3212 |
|
||||||
|
| ingestd | 3213 |
|
||||||
|
| queryd | 3214 |
|
||||||
|
| vectord | 3215 |
|
||||||
|
| embedd | 3216 |
|
||||||
|
| pathwayd | 3217 |
|
||||||
|
| matrixd | 3218 |
|
||||||
|
| observerd | 3219 |
|
||||||
|
| chatd | 3220 |
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
|
||||||
|
### 1. User + directories
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system --no-create-home --shell /usr/sbin/nologin lakehouse
|
||||||
|
sudo mkdir -p /var/lib/lakehouse/{pathway,observer} /var/log/lakehouse \
|
||||||
|
/usr/local/bin/lakehouse /etc/lakehouse
|
||||||
|
sudo chown -R lakehouse:lakehouse /var/lib/lakehouse /var/log/lakehouse
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build + install binaries
|
||||||
|
|
||||||
|
From a clone of the repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.agentview.dev/profit/golangLAKEHOUSE.git
|
||||||
|
cd golangLAKEHOUSE
|
||||||
|
just verify # vet + tests + 9 core smokes — ~31s
|
||||||
|
go build -o bin/ ./cmd/... # 11 binaries land in ./bin/
|
||||||
|
sudo cp bin/{gateway,storaged,catalogd,ingestd,queryd,vectord,embedd,pathwayd,observerd,matrixd,chatd} /usr/local/bin/lakehouse/
|
||||||
|
sudo chmod 755 /usr/local/bin/lakehouse/*
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Config + secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Main config — edit ports/URLs/model tier as needed
|
||||||
|
sudo cp lakehouse.toml /etc/lakehouse/lakehouse.toml
|
||||||
|
|
||||||
|
# S3 credentials — fill in real keys
|
||||||
|
sudo cp deploy/etc-lakehouse/secrets-go.toml.example /etc/lakehouse/secrets-go.toml
|
||||||
|
sudo chown root:lakehouse /etc/lakehouse/secrets-go.toml
|
||||||
|
sudo chmod 0640 /etc/lakehouse/secrets-go.toml
|
||||||
|
sudo $EDITOR /etc/lakehouse/secrets-go.toml # set [s3.primary] keys
|
||||||
|
|
||||||
|
# Auth token — required ONLY if any daemon binds non-loopback
|
||||||
|
sudo cp deploy/etc-lakehouse/auth.env.example /etc/lakehouse/auth.env
|
||||||
|
sudo chown root:lakehouse /etc/lakehouse/auth.env
|
||||||
|
sudo chmod 0640 /etc/lakehouse/auth.env
|
||||||
|
# For non-loopback deploys, set:
|
||||||
|
# AUTH_TOKEN=<generate via `openssl rand -hex 32`>
|
||||||
|
sudo $EDITOR /etc/lakehouse/auth.env
|
||||||
|
|
||||||
|
# Optional: Langfuse traces. When set, every authenticated HTTP
|
||||||
|
# request to every daemon emits a trace + span (production
|
||||||
|
# observability per OPEN item #2 closure). Missing file = no
|
||||||
|
# traces, no warnings.
|
||||||
|
sudo cp deploy/etc-lakehouse/langfuse.env.example /etc/lakehouse/langfuse.env
|
||||||
|
sudo chown root:lakehouse /etc/lakehouse/langfuse.env
|
||||||
|
sudo chmod 0640 /etc/lakehouse/langfuse.env
|
||||||
|
sudo $EDITOR /etc/lakehouse/langfuse.env # set URL + PUBLIC_KEY + SECRET_KEY
|
||||||
|
|
||||||
|
# Optional: chatd cloud provider keys, one file per provider
|
||||||
|
# (each is its own EnvironmentFile so rotations don't restart all chatd)
|
||||||
|
for provider in ollama_cloud openrouter opencode kimi; do
|
||||||
|
echo "${provider^^}_API_KEY=" | sudo tee /etc/lakehouse/$provider.env > /dev/null
|
||||||
|
sudo chown root:lakehouse /etc/lakehouse/$provider.env
|
||||||
|
sudo chmod 0640 /etc/lakehouse/$provider.env
|
||||||
|
done
|
||||||
|
sudo $EDITOR /etc/lakehouse/openrouter.env # etc per provider you need
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. systemd units
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/systemd/*.service deploy/systemd/*.target /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable lakehouse-go.target
|
||||||
|
sudo systemctl start lakehouse-go.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All 11 daemons should be active
|
||||||
|
systemctl status 'lakehouse-*.service' --no-pager | grep -E "Active|●"
|
||||||
|
|
||||||
|
# Health endpoints respond on each port
|
||||||
|
for port in 3110 3211 3212 3213 3214 3215 3216 3217 3218 3219 3220; do
|
||||||
|
printf "%5d: " "$port"
|
||||||
|
curl -sS --max-time 2 "http://127.0.0.1:$port/health" || echo "FAIL"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Through the gateway: all chatd providers register (cloud keys present)
|
||||||
|
curl -sS http://127.0.0.1:3110/v1/chat/providers | jq
|
||||||
|
|
||||||
|
# End-to-end: ingest a tiny CSV → queryd SELECT → matrix.search
|
||||||
|
echo -e "id,name,role\n1,Alice,Forklift Operator" > /tmp/probe.csv
|
||||||
|
curl -sS -F "file=@/tmp/probe.csv" "http://127.0.0.1:3110/v1/ingest?name=probe"
|
||||||
|
curl -sS -X POST http://127.0.0.1:3110/v1/sql \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"sql":"SELECT COUNT(*) FROM probe"}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth posture
|
||||||
|
|
||||||
|
Per ADR-006:
|
||||||
|
|
||||||
|
- **Loopback-only deploy** (every daemon binds 127.0.0.1): no auth needed. Empty `AUTH_TOKEN` is fine. Network is the boundary.
|
||||||
|
- **Non-loopback deploy** (gateway exposed beyond loopback, daemons internal-private): set `AUTH_TOKEN` in `/etc/lakehouse/auth.env`. The mechanical gate at startup refuses to bind without one.
|
||||||
|
- **Multi-host deploy** (gateway + daemons on separate machines): set `AUTH_TOKEN` *and* `[auth].allowed_ips` in lakehouse.toml to the gateway's address. Both layers gate.
|
||||||
|
- **TLS**: terminate at nginx/Caddy in front of the gateway. The Go daemons speak HTTP; in-process TLS is explicitly out of scope per ADR-006 Decision 6.6.
|
||||||
|
|
||||||
|
## Token rotation
|
||||||
|
|
||||||
|
Per ADR-006 Decision 6.5 — dual-token window:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Generate new token
|
||||||
|
NEW=$(openssl rand -hex 32)
|
||||||
|
|
||||||
|
# 2. Add as secondary, keep old as primary
|
||||||
|
sudo sed -i "s|^AUTH_SECONDARY_TOKEN=.*|AUTH_SECONDARY_TOKEN=$NEW|" /etc/lakehouse/auth.env
|
||||||
|
sudo systemctl restart lakehouse-go.target
|
||||||
|
|
||||||
|
# 3. Update every caller to use NEW token
|
||||||
|
# 4. Promote: NEW becomes primary, secondary clears
|
||||||
|
sudo sed -i "s|^AUTH_TOKEN=.*|AUTH_TOKEN=$NEW|" /etc/lakehouse/auth.env
|
||||||
|
sudo sed -i "s|^AUTH_SECONDARY_TOKEN=.*|AUTH_SECONDARY_TOKEN=|" /etc/lakehouse/auth.env
|
||||||
|
sudo systemctl restart lakehouse-go.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker / docker-compose deploy (alternative to systemd)
|
||||||
|
|
||||||
|
The single-image `Dockerfile` carries all 11 daemons; `docker-compose.yml`
|
||||||
|
runs one container per daemon with the same dependency graph as the
|
||||||
|
systemd units. Useful when the host doesn't have systemd (Mac dev
|
||||||
|
boxes, remote VMs without root) or when you want all of Lakehouse-Go
|
||||||
|
isolated to a private docker network.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the image (multi-stage; ~3 min on first build, ~30s with
|
||||||
|
# cached go module download).
|
||||||
|
docker build -t lakehouse-go:latest .
|
||||||
|
|
||||||
|
# Place config + secrets next to docker-compose.yml. The compose file
|
||||||
|
# bind-mounts these into every container at /etc/lakehouse/.
|
||||||
|
cp lakehouse.toml lakehouse.toml # already in repo; edit if needed
|
||||||
|
cp deploy/etc-lakehouse/secrets-go.toml.example secrets-go.toml
|
||||||
|
chmod 0600 secrets-go.toml
|
||||||
|
cp deploy/etc-lakehouse/auth.env.example auth.env
|
||||||
|
chmod 0600 auth.env
|
||||||
|
# Per-provider chatd keys (each its own file so missing == provider
|
||||||
|
# unregistered, NOT chatd startup failure):
|
||||||
|
for p in ollama_cloud openrouter opencode kimi; do
|
||||||
|
echo "${p^^}_API_KEY=" > $p.env
|
||||||
|
chmod 0600 $p.env
|
||||||
|
done
|
||||||
|
|
||||||
|
# $EDITOR each file to fill in real values...
|
||||||
|
|
||||||
|
# Bring up the stack.
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps # all 11 services Healthy
|
||||||
|
docker compose logs -f gateway
|
||||||
|
|
||||||
|
# Validate via the gateway like the systemd path.
|
||||||
|
curl -sS http://127.0.0.1:3110/v1/chat/providers | jq
|
||||||
|
|
||||||
|
# Tear down.
|
||||||
|
docker compose down
|
||||||
|
# State volume (pathway/observer JSONLs) survives `down`. To wipe:
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key docker-vs-systemd differences
|
||||||
|
|
||||||
|
| Concern | systemd | docker-compose |
|
||||||
|
|---|---|---|
|
||||||
|
| Process supervision | systemd | tini + docker daemon |
|
||||||
|
| Logs | journald | `docker logs` (or routed to a sink via logging driver) |
|
||||||
|
| Restarts on failure | `Restart=on-failure` | `restart: unless-stopped` |
|
||||||
|
| File ownership | `User=lakehouse` (uid varies) | `user: 999:999` (uid is fixed in the image) |
|
||||||
|
| Reaches MinIO/Ollama | host network | host's address from inside the bridge network — typically `host.docker.internal` (Mac/Win) or `172.17.0.1` (Linux). Set `[s3].endpoint` + `[embedd].provider_url` accordingly. |
|
||||||
|
| Backup target | `/var/lib/lakehouse/` on host | the `lakehouse-state` named volume; bind to a host path via the commented-out `driver_opts` in compose if needed |
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
systemd routes everything to journald with per-daemon SyslogIdentifier:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u lakehouse-gateway.service -f
|
||||||
|
journalctl -u 'lakehouse-*.service' --since '5 min ago'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stopping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop lakehouse-go.target # cascades to all 11 daemons
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup / state preservation
|
||||||
|
|
||||||
|
| Path | What | Backup priority |
|
||||||
|
|---|---|---|
|
||||||
|
| `/var/lib/lakehouse/pathway/state.jsonl` | Mem0 trace store (append-only) | high |
|
||||||
|
| `/var/lib/lakehouse/observer/ops.jsonl` | observer ring's persistor backup | medium |
|
||||||
|
| MinIO `lakehouse-go-primary` bucket | parquets, vector LHV1 indexes, catalog manifests | high |
|
||||||
|
| `/etc/lakehouse/lakehouse.toml` | service config | medium |
|
||||||
|
| `/etc/lakehouse/secrets-go.toml` + `*.env` | secrets | high (in your secrets manager, not on disk) |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Daemon refuses to start with "refuse non-loopback bind without auth.token"**
|
||||||
|
ADR-006 6.1 mechanical gate. Set `AUTH_TOKEN` in `/etc/lakehouse/auth.env` or bind back to loopback.
|
||||||
|
|
||||||
|
**Daemon refuses to start with "refusing non-loopback bind ... see audit R-001"**
|
||||||
|
The previous loopback-bind gate. For dev: `LH_<NAME>_ALLOW_NONLOOPBACK=1` overrides. For prod: set `AUTH_TOKEN` AND keep the override (or move to loopback + reverse-proxy).
|
||||||
|
|
||||||
|
**catalogd 500 / NoSuchBucket**
|
||||||
|
storaged is pointing at a bucket that doesn't exist. Either create the bucket in MinIO or fix `[s3].bucket` in lakehouse.toml.
|
||||||
|
|
||||||
|
**embedd 502 on /v1/embed**
|
||||||
|
Ollama not running OR `[embedd].default_model` not loaded. `ollama list` to verify; `ollama pull nomic-embed-text-v2-moe` to load.
|
||||||
|
|
||||||
|
**chatd `/v1/chat/providers` shows `false` for cloud providers**
|
||||||
|
The provider's env file is missing or empty. Check `/etc/lakehouse/<provider>.env`.
|
||||||
|
|
||||||
|
**queryd unable to read parquet**
|
||||||
|
Check `[queryd].secrets_path` points at the right secrets-go.toml AND the file's owner+mode allow the lakehouse user to read.
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- `STATE_OF_PLAY.md` — verified-working snapshot
|
||||||
|
- `docs/DECISIONS.md` — all ADRs, especially ADR-003 (auth substrate) + ADR-006 (auth posture)
|
||||||
|
- `docs/SPEC.md` §1 — component table
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# STATE OF PLAY — Lakehouse-Go
|
||||||
|
|
||||||
|
**Last verified:** 2026-05-02 ~11:30 CDT
|
||||||
|
**Verified by:** **production-readiness gauntlet** — 21/21 smoke chain green, per-component scrum across 4 bundles, **6 cross-runtime parity probes all green: 38/38 byte-identical assertions** (validator 6/6 after wire-format alignment, materializer 2/2 after omitempty fix, extract_json 12/12, session_log 4/4, embed 8/8 cosine, subject_audit 6/6 after the three runtime-divergence-class fixes documented at `lakehouse/STATE_OF_PLAY.md` lines 36-39). All findings surfaced by the parity probes have been actioned. Disposition: `reports/cutover/gauntlet_2026-05-02/disposition.md`. Subject-audit probe added 2026-05-03 (`262a77a`).
|
||||||
|
|
||||||
|
> **Read this FIRST.** When the user says "we're working on lakehouse," default to the Go rewrite (this repo) but ASK if ambiguous — the Rust system at `/home/profit/lakehouse/` is also receiving real work as of 2026-05-02 (Lance backend hardening, observability parity, sidecar drop — see `lakehouse/STATE_OF_PLAY.md`). The cross-runtime decisions tracker at `docs/ARCHITECTURE_COMPARISON.md` is the single source of truth for what's open across both. If memory contradicts this file, this file wins. Update it when something is verified working — not when a phase finishes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VERIFIED WORKING RIGHT NOW
|
||||||
|
|
||||||
|
### Substrate (G0 + G1 family)
|
||||||
|
|
||||||
|
14 service binaries under `cmd/` plus 2 driver scripts (`scripts/staffing_*`) and 3 distillation tools (`cmd/audit_full`, `cmd/materializer`, `cmd/replay`) build into `bin/`. **21 smoke scripts all PASS** (added `validatord_smoke.sh` 2026-05-02). `just verify` (vet + 33 packages × short tests + 9 core smokes) green in ~32s wall.
|
||||||
|
|
||||||
|
| Binary | Port | What |
|
||||||
|
|---|---|---|
|
||||||
|
| `gateway` | 3110 | reverse proxy, single OpenAI-compat-style edge |
|
||||||
|
| `storaged` | 3211 | S3 GET/PUT/LIST/DELETE w/ per-prefix PUT cap (ADR-002) |
|
||||||
|
| `catalogd` | 3212 | Parquet manifests, ADR-020 idempotent register |
|
||||||
|
| `ingestd` | 3213 | CSV → Parquet → catalogd, content-addressed keys |
|
||||||
|
| `queryd` | 3214 | DuckDB SELECT over Parquet via httpfs |
|
||||||
|
| `vectord` | 3215 | HNSW indexes (coder/hnsw), persistence to storaged |
|
||||||
|
| `embedd` | 3216 | Ollama-backed embedder w/ LRU cache |
|
||||||
|
| `pathwayd` | 3217 | Mem0 ops (Add/Update/Revise/Retire/History/Search) |
|
||||||
|
| `matrixd` | 3218 | Multi-corpus retrieve+merge + relevance + downgrade + playbook |
|
||||||
|
| `observerd` | 3219 | Witness loop, workflow runner with DAG executor |
|
||||||
|
| `chatd` | 3220 | LLM dispatcher: ollama / ollama_cloud / openrouter / opencode / kimi |
|
||||||
|
| `validatord` | 3221 | `/validate` (FillValidator + EmailValidator + PlaybookValidator) + `/iterate` (gen→validate→correct loop). Roster from JSONL. |
|
||||||
|
| `mcpd` | — | MCP SDK port (Bun mcp-server replacement) |
|
||||||
|
| `fake_ollama` | — | Test fixture (used by `g2_smoke_fixtures.sh`) |
|
||||||
|
|
||||||
|
### Matrix indexer — all 5 SPEC §3.4 components shipped
|
||||||
|
|
||||||
|
1. **Corpus builders** (`internal/corpusingest`)
|
||||||
|
2. **Multi-corpus retrieve+merge** (`matrixd /matrix/search`)
|
||||||
|
3. **Relevance filter** (`internal/matrix/relevance.go` 376 LoC + 289 LoC test)
|
||||||
|
4. **Strong-model downgrade gate** (`internal/matrix/downgrade.go`, reads `cfg.Models.WeakModels` after Phase 2)
|
||||||
|
5. **Playbook memory: boost + Shape B inject** (`internal/matrix/playbook.go`, learning loop). Shape B (`InjectPlaybookMisses`, `154a72e`) appends recorded answers to results when regular retrieval misses them — closed the paraphrase recovery gap exposed by run #002 (0/2) and validated by run #003 (6/6).
|
||||||
|
|
||||||
|
### Pathway memory (Mem0 substrate)
|
||||||
|
|
||||||
|
Full ADR-004 surface shipped. **Cycle-detection + retired-trace exclusion proven by tests:** `TestHistory_CycleDetected`, `TestRetire_ExcludedFromSearch`, `TestRevise_ChainOfThree_BackwardWalk`. JSONL append-only persistence with corruption tolerance.
|
||||||
|
|
||||||
|
### Observer + workflow runner
|
||||||
|
|
||||||
|
- `observerd` ring buffer + JSONL persistence
|
||||||
|
- Workflow DAG executor (Archon-style) with 5 native modes wired: `matrix.relevance`, `matrix.downgrade`, `matrix.search`, `distillation.score`, `drift.scorer`. Plus `fixture.echo` / `fixture.upper` for runner mechanics smokes.
|
||||||
|
|
||||||
|
### Distillation + drift
|
||||||
|
|
||||||
|
- **E (partial)** at `57d0df1` — scorer + contamination firewall ported from Rust v1.0.0 (logic only per ADR-001 §1.4; not bit-identical).
|
||||||
|
- **F (first slice)** at `be65f85` — drift quantification, scorer drift first.
|
||||||
|
- **Materializer port** (2026-05-02) — `internal/materializer` + `cmd/materializer`. Ports `scripts/distillation/transforms.ts` (12 transforms) + `build_evidence_index.ts` (idempotency, day-partition, receipt). On-wire JSON shape matches TS so Bun and Go runs are interchangeable. 14 tests + `materializer_smoke.sh`.
|
||||||
|
- **Replay port** (2026-05-02) — `internal/replay` + `cmd/replay`. Ports `scripts/distillation/replay.ts` (retrieve → bundle → /v1/chat → validate → log). Closes audit-FULL phase 7 live invocation on the Go side. Both runtimes append to the same `data/_kb/replay_runs.jsonl` (`schema=replay_run.v1`). 14 tests + `replay_smoke.sh`.
|
||||||
|
|
||||||
|
### chatd — Phase 4 (shipped 2026-04-30, scrum-hardened same day)
|
||||||
|
|
||||||
|
Multi-provider LLM dispatcher routing `/v1/chat` by model-name prefix or `:cloud` suffix:
|
||||||
|
|
||||||
|
| Prefix / suffix | Provider | Auth |
|
||||||
|
|---|---|---|
|
||||||
|
| `ollama/<m>` or bare | `ollama` (local) | none |
|
||||||
|
| `ollama_cloud/<m>` or `<m>:cloud` | `ollama_cloud` | Bearer (OLLAMA_CLOUD_KEY) |
|
||||||
|
| `openrouter/<v>/<m>` | `openrouter` | Bearer (OPENROUTER_API_KEY) |
|
||||||
|
| `opencode/<m>` | `opencode` | Bearer (OPENCODE_API_KEY) |
|
||||||
|
| `kimi/<m>` | `kimi` | Bearer (KIMI_API_KEY) |
|
||||||
|
|
||||||
|
All 5 keys live in `/etc/lakehouse/{ollama_cloud,openrouter,opencode,kimi}.env` files (mode 0600). Empty/missing files leave that provider unregistered (404 at first call instead of 503). Test request: `POST /v1/chat {"model":"opencode/claude-opus-4-7","messages":[{"role":"user","content":"hi"}],"max_tokens":8}`.
|
||||||
|
|
||||||
|
`Request.Temperature` is `*float64` (pointer) — Anthropic 4.7 deprecates `temperature` entirely, so we omit the field when caller doesn't set it.
|
||||||
|
|
||||||
|
### Model tier registry
|
||||||
|
|
||||||
|
`lakehouse.toml [models]` names model IDs by tier so swaps are 1-line:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
local_fast = "qwen3.5:latest"
|
||||||
|
local_judge = "qwen2.5:latest" # NOT qwen3.5 — vision-SSM 256K-ctx ran 30s/judge in lift loop
|
||||||
|
cloud_judge = "kimi-k2.6:cloud"
|
||||||
|
cloud_review = "qwen3-coder:480b"
|
||||||
|
frontier_review = "openrouter/anthropic/claude-opus-4-7"
|
||||||
|
frontier_arch = "openrouter/moonshotai/kimi-k2-0905"
|
||||||
|
frontier_free = "opencode/claude-opus-4-7"
|
||||||
|
weak_models = ["qwen3.5:latest", "qwen3:latest"] # matrix.downgrade bypass
|
||||||
|
```
|
||||||
|
|
||||||
|
Callers read `cfg.Models.LocalJudge` etc. instead of literal strings. `playbook_lift` harness, `matrix.downgrade`, and observerd's `MatrixDowngradeWithWeakList` factory all migrated.
|
||||||
|
|
||||||
|
### Code health
|
||||||
|
|
||||||
|
- `go vet ./...` → **0 warnings, 0 errors**
|
||||||
|
- `go test -short ./...` → **all green**, 349 test functions
|
||||||
|
- `just verify` → PASS (vet + tests + 9 smokes) in ~31s
|
||||||
|
- 18 smoke scripts (9 core gating verify + 9 domain smokes for new daemons)
|
||||||
|
|
||||||
|
### Latest scrum: 2026-04-30 cross-lineage wave
|
||||||
|
|
||||||
|
Composite **50/60** at scrum2 head `c7e3124` (was 35 baseline → 43 R1 → 50 R2). Today's chatd wave reviewed by Opus + Kimi + Qwen3-coder via the chatd's own `/v1/chat`; **2 BLOCKs + 2 WARNs landed as fixes** (`0efc736`); reusable driver at `scripts/scrum_review.sh`.
|
||||||
|
|
||||||
|
### Reality tests #001–#003 — load-bearing gate verified (2026-04-30 ~05:50–07:05 CDT)
|
||||||
|
|
||||||
|
The 5-loop substrate's load-bearing gate (per `project_small_model_pipeline_vision.md`: *"the playbook + matrix indexer must give the results we're looking for"*) is verified for both **verbatim replay** and **paraphrase queries**.
|
||||||
|
|
||||||
|
| Run | Stance | Verbatim lift | Paraphrase recovery | What it proved |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `playbook_lift_001` | boost-only | **7/8 (87.5%)** | not tested | Cosine + boost re-rank works for verbatim replay. Substrate live. |
|
||||||
|
| `playbook_lift_002` | boost-only | 2/2 | **0/2** | Boost can't promote answers OUT of regular top-K — paraphrase gap exposed. |
|
||||||
|
| `playbook_lift_003` | Shape B (loose 0.5) | 2/6 | 6/6 → top-1 | Shape B injects, but cross-pollinates: w-4435 surfaces as warm top-1 for unrelated OOD queries (dental/RN/SWE). |
|
||||||
|
| `playbook_lift_004` | **Shape B + split threshold (0.5 boost / 0.20 inject)** | **6/8 (75%)** | **6/8 (75%)** | OOD cross-pollination GONE; system refuses to inject when it's not confident. The honest configuration. |
|
||||||
|
|
||||||
|
**Shape B** (`InjectPlaybookMisses` in `internal/matrix/playbook.go`): when warm-pass retrieval doesn't already include a playbook hit's answer, append a synthetic Result with distance = `playbook_hit_distance × BoostFactor`. Caller re-sorts + truncates. Documented at `playbook.go:22-27` since v0; v3 shipped the implementation. v4 added the split-threshold defense (`DefaultPlaybookMaxInjectDistance = 0.20` while boost stays at 0.50) — boost is safe at loose thresholds because it only re-ranks results already in retrieval; inject is structurally riskier so its threshold is tighter.
|
||||||
|
|
||||||
|
OOD honesty (dental hygienist / RN / software engineer queries) holds across all three runs — judge rates them 1, system doesn't fabricate matches. Cross-corpus boosts (e- ↔ w- swaps) confirmed in v1 + v3.
|
||||||
|
|
||||||
|
Evidence: `reports/reality-tests/playbook_lift_{001,002,003}.{json,md}`. Per the report's rubric (lift ≥ 50% = matrix doing real work), 6/6 paraphrase recovery is the validation that matters — verbatim replay is structurally the easy case.
|
||||||
|
|
||||||
|
**v3 → v4 is the configuration evolution.** v3 ran with one threshold (0.5) for both boost and inject — paraphrase recovery hit 6/6 but a single strong recording (w-4435 for "OSHA-30 forklift Wisconsin") cross-pollinated to OOD queries (dental hygienist / RN / software engineer) because their text vectors fell within 0.5 cosine. v4 split: boost stays at 0.5 (safe — only re-ranks existing results), inject tightens to 0.20 (true paraphrase territory). Result: OOD cross-pollination eliminated, paraphrase recovery 6/8, verbatim lift 6/8. The two paraphrases that missed (Q9, Q15) were liberally rephrased — drift > 0.20 cosine.
|
||||||
|
|
||||||
|
### Multi-coordinator stress test (Phase 1 → 3) — VERIFIED end-to-end
|
||||||
|
|
||||||
|
Reality test #2 catalog. New harness `scripts/multi_coord_stress.{sh,go}` simulates 3 coordinators (alice/bob/carol) handling 3 distinct contracts (Milwaukee distribution / Indianapolis manufacturing / Chicago construction), each with their own playbook namespace. 9-phase operational narrative across simulated Hours 0–48: baseline → fresh-resume injection → inbox burst → mid-day surge → 200-worker swap → contract merge → handover (verbatim + paraphrase) → split → reissue.
|
||||||
|
|
||||||
|
| Capability | Verified | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| Per-coordinator playbook isolation | ✓ | `playbook_alice` / `playbook_bob` / `playbook_carol` corpora |
|
||||||
|
| Same-role-across-contracts diversity | Jaccard 0.026 (n=9) — 97% workers differ per region | Phase 1 baseline |
|
||||||
|
| Different-roles-same-contract diversity | Jaccard 0.070 (n=18) — 93% differ per role | Phase 1 baseline |
|
||||||
|
| HNSW retrieval determinism | Jaccard 1.000 (n=12) | Phase 6 reissue |
|
||||||
|
| Verbatim handover (Bob runs Alice's queries with Alice's playbook) | 4/4 | Phase 4 |
|
||||||
|
| Paraphrase handover (Bob runs qwen2.5-paraphrased queries) | 4/4 | Phase 4b |
|
||||||
|
| 200-worker swap with `ExcludeIDs` | Jaccard 0.000 — 8/8 placed workers fully replaced | Phase 2b |
|
||||||
|
| Fresh-resume injection (two-tier `fresh_workers` index) | 3/3 fresh workers at top-1 | Phase 1b |
|
||||||
|
| Inbox endpoint `/v1/observer/inbox` (email + SMS, priority weighting) | 6/6 events recorded | Phase 1c |
|
||||||
|
| LLM demand parsing (qwen2.5 format=json on inbox bodies) | 6/6 parsed cleanly into structured `{role, count, location, certs, skills, shift}` | Phase 1c |
|
||||||
|
| Judge re-rates inbox top-1 against ORIGINAL body | catches tight-distance-but-wrong (Q3 crane case: dist 0.23 → rating 1) | Phase 1c |
|
||||||
|
| Langfuse Go-side tracing | 111 observations on a single run trace, browseable at http://localhost:3001 | Run #011 |
|
||||||
|
|
||||||
|
**Substrate gains added by this wave:**
|
||||||
|
- `internal/matrix/playbook.go` Shape B + split inject threshold (commit `67d1957` from earlier wave; verified in multi-coord too)
|
||||||
|
- `internal/matrix/retrieve.go` `ExcludeIDs` field on `SearchRequest` — filters worker IDs at retrieve, boost, AND inject (so excluded answers can't sneak back via playbook). Real-world driver: coordinator placed N workers, client asks for replacements.
|
||||||
|
- `internal/observer/types.go` `SourceInbox` taxonomy alongside `SourceMCP / SourceScenario / SourceWorkflow`
|
||||||
|
- `cmd/observerd` `POST /observer/inbox` route — accepts `{type, sender, subject, body, priority, tag}` and records as `ObservedOp`. Type must be `email` or `sms`; body required; priority defaults to medium.
|
||||||
|
- `internal/langfuse/client.go` — minimal Go-side Trace+Span client, best-effort posture (logs on error, never blocks calling path; same fail-open semantics as ADR-005 Decision 5.1).
|
||||||
|
- `embedd default_model` bumped from `nomic-embed-text` (137M) → `nomic-embed-text-v2-moe` (475M, MoE, drop-in 768d). Same-role-across-contracts diversity went from 0.080 → 0.000 with the upgrade.
|
||||||
|
- Two-tier index pattern: fresh content goes to `fresh_workers` (a small "hot" corpus); main queries include it in the corpora list. Solves the HNSW post-build add recall issue (incremental adds to a populated graph land in poorly-connected regions and disappear from search). Canonical NRT pattern; Lucene works the same way.
|
||||||
|
|
||||||
|
### Harness expansion (2026-04-30 ~05:30 CDT)
|
||||||
|
|
||||||
|
`scripts/playbook_lift.sh` rewritten from a 5-daemon stripped harness to the **full 10-daemon prod-realistic stack** (chatd stays up independently). The 5-daemon version was structurally hiding bugs; expanding the daemon set surfaced 7 distinct fixes:
|
||||||
|
|
||||||
|
| # | Fix | Lock |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | driver→matrixd: `query` → `query_text` field name | `cmd/matrixd/main_test.go` TestPlaybookRecord_OldFieldNameRejected |
|
||||||
|
| 2 | harness toml missing `[s3]` block | inline comment in `scripts/playbook_lift.sh` |
|
||||||
|
| 3 | harness→queryd: `q` → `sql` field name | `cmd/queryd/main_test.go` TestHandleSQL_WrongFieldName_400 |
|
||||||
|
| 4 | 5→10 daemon boot order | inline comment + dep-ordered launch |
|
||||||
|
| 5 | SQL surface probe (3-row CSV → COUNT=3) | `[lift] ✓ SQL surface probe passed` assertion |
|
||||||
|
| 6 | `candidates` corpus was SWE-tech, not staffing | swapped to `ethereal_workers.parquet` (10K rows, real staffing schema, "e-" id prefix) |
|
||||||
|
| 7 | `qwen3.5:latest` is vision-SSM 256K-ctx → 30s/judge | reverted `local_judge` to `qwen2.5:latest` (1s/judge, 30× faster) |
|
||||||
|
|
||||||
|
### R-005 closed (2026-04-30 ~05:35 CDT)
|
||||||
|
|
||||||
|
Four new `cmd/<bin>/main_test.go` files — chi router-level contract tests:
|
||||||
|
|
||||||
|
- `cmd/matrixd/main_test.go` (123 lines) — playbook record drift detector + score bounds + 6 routes mounted
|
||||||
|
- `cmd/queryd/main_test.go` (extended) — wrong-field-name drift detector
|
||||||
|
- `cmd/pathwayd/main_test.go` (102 lines) — 9 routes + add round-trip + retire-nonexistent
|
||||||
|
- `cmd/observerd/main_test.go` (98 lines) — 4 routes + invalid-op 400 + unknown-mode 400
|
||||||
|
|
||||||
|
`go test ./cmd/{matrixd,queryd,pathwayd,observerd}` all green. R-005 from prior STATE OPEN list is closed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DO NOT RELITIGATE
|
||||||
|
|
||||||
|
### Ratified ADRs (`docs/DECISIONS.md`)
|
||||||
|
|
||||||
|
- **ADR-001**: DuckDB via cgo, HTMX UI, Gitea hosting, distillation rebuilt-not-ported, pathway memory clean start, auditor longitudinal signal restarts. **6 sub-decisions, all final.**
|
||||||
|
- **ADR-002**: storaged per-prefix PUT cap (4 GiB for `_vectors/`, 256 MiB elsewhere) — implemented at `423a381`. Operator-config bump rather than constant change is the documented path if 4 GiB ever insufficient.
|
||||||
|
- **ADR-003**: Inter-service auth = Bearer + IP allowlist, opt-in via `cfg.Auth.Token`. Wiring deferred to Sprint 1 but **the design is locked** — alternatives (mTLS, JWT, OAuth2, IP-only) all considered + rejected.
|
||||||
|
- **ADR-004**: Pathway memory = Mem0 versioned traces, JSONL append-only persistence, opaque `json.RawMessage` content. Implemented in `internal/pathway/`.
|
||||||
|
|
||||||
|
### Today's scrum dispositions (2026-04-30)
|
||||||
|
|
||||||
|
Verbatim verdicts at `reports/scrum/_evidence/2026-04-30/verdicts/`. Disposition table: `reports/scrum/_evidence/2026-04-30/disposition.md`.
|
||||||
|
|
||||||
|
**Real findings, all fixed in `0efc736`:**
|
||||||
|
- B-1 (Opus+Kimi convergent): `ResolveKey` 3-arg API → 2-arg
|
||||||
|
- B-2 (Opus+Kimi convergent): `handleProviders` direct map lookup, drop synthesis-via-Resolve
|
||||||
|
- B-3 (Opus single, trace-verified): `OllamaCloud.Chat` strips `ollama_cloud/` prefix correctly
|
||||||
|
- B-4 (Opus single): Ollama `done_reason` surfaced to FinishReason
|
||||||
|
|
||||||
|
**False positives dismissed (3, documented):**
|
||||||
|
- FP-A1: Kimi misread `TestMaybeDowngrade_WithConfigList` assertion
|
||||||
|
- FP-A2: Qwen claimed nil-deref in `MaybeDowngrade` that doesn't exist
|
||||||
|
- FP-C1: Opus claimed `qwen3.5:latest` doesn't exist on Ollama hub (it does on this box's local install)
|
||||||
|
|
||||||
|
### Session frame (don't redo)
|
||||||
|
|
||||||
|
- The Rust system is **NOT maintenance-only** as of 2026-05-02 — it's receiving parity work + infrastructure (Lance gauntlet, sidecar drop, observability parity). Don't propose ports of components already shipped on the Go side, but do expect Rust commits to land in parallel for cross-runtime fabric.
|
||||||
|
- The matrix indexer **5/5 components** are shipped. Don't propose to "build the matrix indexer" — it's done.
|
||||||
|
- The 5-loop substrate's load-bearing gate is **PASSED**. v3 (`154a72e`) showed 6/6 paraphrase recovery via Shape B. Don't propose to "test if the playbook learns" — it does.
|
||||||
|
- **Shape B is the playbook stance now.** When `use_playbook=true`, both `ApplyPlaybookBoost` (re-rank in place) AND `InjectPlaybookMisses` (insert recorded answers not in regular top-K) run. Don't revert to boost-only; v2 proved that path can't recover paraphrase queries.
|
||||||
|
- **Boost / inject use SEPARATE thresholds.** Boost stays at `DefaultPlaybookMaxDistance = 0.5` (safe — only re-ranks results already in retrieval). Inject uses tighter `DefaultPlaybookMaxInjectDistance = 0.20` (Shape B forces results in, so loose match cross-pollinates wrong-domain). Don't merge them.
|
||||||
|
- **Multi-coord product theory is empirically VALIDATED** by run #011 (Phase 3). Per-coordinator playbook namespaces (`playbook_alice` etc.) with cross-coordinator handover (Bob takes Alice's contract using `playbook_corpus=playbook_alice`) work end-to-end including paraphrase handover. Don't propose to "test if multi-coord works" — it does.
|
||||||
|
- **Auth posture is locked per ADR-006.** Non-loopback bind requires `auth.token` (mechanical gate at `shared.Run`). Operators set the token via `token_env` (defaults to `AUTH_TOKEN`) loaded by systemd `EnvironmentFile=/etc/lakehouse/auth.env`, NOT in the committed TOML. Internal services use `AllowedIPs`; external boundary uses Bearer. Token rotation is dual-token via `secondary_tokens`. TLS terminates at the edge (nginx/Caddy), not in-process. Don't re-litigate.
|
||||||
|
- **Shape B inject has a judge-gate substrate.** `InjectPlaybookMisses` takes an optional `InjectGate` (interface) that approves each candidate before the rank insert. `LLMJudgeGate` (Ollama-shape /api/chat client) is the default impl; nil gate = pre-judge-gating distance-only behavior preserved for backward compat. Caller wires via `SearchRequest.{JudgeURL, JudgeModel, JudgeMinRating}`. Closes the lift-suite tail issues (Q6↔Q7 adjacent-query swap + Q9/Q15 paraphrase drift) at substrate level.
|
||||||
|
- **Fresh content uses two-tier indexing.** Fresh resumes go to `fresh_workers` corpus, not the main `workers` index. coder/hnsw incremental adds to a populated 5K+ graph have recall issues; the small hot index has no such crowding. Periodic merge (post-G3) consolidates fresh→main. Canonical NRT pattern.
|
||||||
|
- **`embedd.default_model = "nomic-embed-text-v2-moe"`** (475M MoE, 768d). Don't bump to `nomic-embed-text` (137M) "for speed" — diversity scores fell from 0.000 → 0.080 same-role-across-contracts on the smaller model. Cost (~5× slower ingest) is acceptable for once-per-deploy work.
|
||||||
|
- **Inbox flow: parse + search + judge + trace.** `/v1/observer/inbox` records the body; coordinator/driver parses via LLM (qwen2.5 format=json), runs `matrix.search` on the parsed query, judges top-1 against the ORIGINAL body, emits Langfuse spans through it all. Don't replace the judge re-rate with a distance-only gate — tight distance + low rating is the load-bearing honesty signal.
|
||||||
|
- `local_judge = "qwen2.5:latest"` for the lift loop — qwen3.5:latest is a vision-SSM 256K-ctx build that ran 30s/judge. Don't bump to qwen3.5 again "for cleanliness." Different model in the same tier has different cost geometry.
|
||||||
|
- `qwen3.5:latest` IS available locally on this box. Opus's hub-only knowledge is a known-stale signal; the chatd_smoke uses it daily.
|
||||||
|
- `temperature` is **omitted** for Anthropic 4.7 (handled by `Request.Temperature *float64`); don't re-add it.
|
||||||
|
- chatd-smoke runs with **all cloud providers disabled** intentionally so the suite doesn't depend on API keys; that's why it can't catch B-3-class bugs (those need a fake-server fixture, see Sprint 0 follow-up).
|
||||||
|
- **Langfuse Go-side client lives at `internal/langfuse/`** with best-effort fail-open posture. URL+creds from `/etc/lakehouse/langfuse.env`. Don't propose to "wire Langfuse on Go side" — it's wired; multi_coord_stress is the proof.
|
||||||
|
- **vectord's source-of-truth is `i.vectors`, NOT the coder/hnsw graph.** The `Index` struct holds a parallel `vectors map[string][]float32` updated on every successful Add/Delete; the graph is a derived, replaceable view. `safeGraphAdd`/`safeGraphDelete` wrap the library's panic-prone ops; `rebuildGraphLocked` reads from `i.vectors` (graph-state-independent). Don't propose to "drop the side map for memory" — it's the load-bearing piece that makes Add panic-recoverable past the small-index threshold (closes the multitier_100k 277884b 96-98% fail). The prior `i.ids` set was folded into `i.vectors` keys.
|
||||||
|
- **vectord saves are coalesced async, not synchronous.** `cmd/vectord/main.go` runs a per-index `saveTask` that single-flights through `Persistor.Save` — at most one in-flight + one pending. Add returns OK before the save completes; an Add-then-crash can lose ~1 save's worth of data, matching ADR-005's fail-open posture. Don't propose to "make saves synchronous for durability" — that re-introduces the lock-contention bottleneck (1-2.5s tail at conc=50, observed 2026-05-01) without fixing a real durability hole (in-memory state is the source of truth in flight).
|
||||||
|
- **`X-Lakehouse-Trace-Id` header propagates Langfuse parent traces across daemon boundaries.** When validatord's `/v1/iterate` calls chatd's `/v1/chat`, it forwards the header so chatd's middleware reuses the parent trace id instead of minting a new one. Each iteration attempt also emits a child span (`iterate.attempt[N]`) carrying the prompt, raw model output, and validator verdict. Result: an iterate session with N retries shows in Langfuse as ONE trace tree, not N+1 disconnected traces. Don't propose to "wire trace-id propagation" — it's wired; the test at `internal/shared/langfuse_middleware_test.go::TestLangfuseMiddleware_HonorsTraceIDHeader` is the proof. Closes J's 2026-05-02 multi-call observability concern.
|
||||||
|
- **Coordinator session JSONL exists at `[validatord].session_log_path`.** One row per `/v1/iterate` session, schema=`session.iterate.v1`, append-only. Captures session_id (= Langfuse trace_id), kind, model, iterations, attempts[], final_verdict, **grounded_in_roster** (the load-bearing forensic property — always true on accepted fills, false on phantom-worker artifacts that somehow slip past FillValidator). Empty path = disabled. DuckDB queries documented at `docs/SESSION_LOG.md`. Don't propose to "build offline session analysis" — it's built; the schema is locked at v1; rotate the file via logrotate when it gets large.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OPEN — what's not done yet
|
||||||
|
|
||||||
|
The list is intentionally short. Items move to closed when the work demands them, not on a calendar. Ordered by leverage on the active product theory (multi-coord staffing co-pilot via the 5-loop substrate), not by effort.
|
||||||
|
|
||||||
|
**All 4 prior OPEN items closed (substrate or fully) in the 2026-04-30
|
||||||
|
"fix the other 4" wave.** No new items pending; the substrate is in
|
||||||
|
a steady state. Future items will land here as production triggers fire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RECENT VERIFIED WAVE (2026-04-30)
|
||||||
|
|
||||||
|
`05273ac..e4ee002` — 4 phases + scrum + tooling, all gate-tested.
|
||||||
|
|
||||||
|
| SHA | What |
|
||||||
|
|---|---|
|
||||||
|
| `ec1d031` | Phase 1: `[models]` tier config (additive, no callers migrate) |
|
||||||
|
| `622e124` | Phase 2: `matrix.downgrade` reads `cfg.Models.WeakModels` |
|
||||||
|
| `848cbf5` | Phase 3: `playbook_lift` harness defaults from config |
|
||||||
|
| `05273ac` | Phase 4: chatd + 5 providers (1,624 LoC) |
|
||||||
|
| `0efc736` | Scrum: 4 fixes (B-1..B-4) + 2 INFOs from cross-lineage review |
|
||||||
|
| `e4ee002` | `scripts/scrum_review.sh` — reusable 3-lineage driver |
|
||||||
|
| `b2e45f7` | playbook_lift harness expansion + reality test #001 (7/8 lift, 87.5%) |
|
||||||
|
| `6c02c90` | scrum lift_001: 4 fixes (sleep→polling SQL probe, JUDGE_SOURCE template, -id-prefix validation, chi.Router cast) |
|
||||||
|
| `2c71d1c` | ADR-005: observer fail-safe semantics |
|
||||||
|
| `9ce067b` | observerd: test that locks ADR-005 5.3 (provenance recorded post-run) |
|
||||||
|
| `e9822f0` | playbook_lift v2: paraphrase pass — exposed boost-only limit (0/2 paraphrase recovery) |
|
||||||
|
| `154a72e` | matrix: Shape B (`InjectPlaybookMisses`) — 6/6 paraphrase recovery in run #003 |
|
||||||
|
| `94fc3b6` | STATE_OF_PLAY: capture v3 reality test + Shape B + cross-pollination |
|
||||||
|
| `67d1957` | matrix: split boost/inject thresholds (0.5 / 0.20) — kills cross-pollination, run #004 6/8+6/8 |
|
||||||
|
| `b13b5cd` | playbook_lift v4 metric: warm-top-1 re-judge → quality lift +24%/-14% (5 lifted / 13 neutral / 3 regressed) |
|
||||||
|
| `61c7b55` | multi-coord stress harness (Phase 1) — 3 coords / 3 contracts / 7-phase scenario |
|
||||||
|
| `0fa42a0` | multi-coord stress Phase 1.5 — shared-role contracts + paraphrase handover |
|
||||||
|
| `84a32f0` | multi-coord stress Phase 2 — `ExcludeIDs`, 200-worker swap, fresh-resume |
|
||||||
|
| `4da32ad` | embedd: bump default to nomic-embed-text-v2-moe (475M MoE, 768d drop-in) |
|
||||||
|
| `e7fc63b` | observerd `/observer/inbox` + multi-coord stress phase 1c (priority-ordered events) |
|
||||||
|
| `186d209` | multi_coord_stress: LLM-parsed inbox demands (qwen2.5 format=json) |
|
||||||
|
| `ce940f4` | multi_coord_stress: judge re-rates inbox top-1 against original body — recovers OOD honesty signal |
|
||||||
|
| `7e6431e` | langfuse: Go-side client + Phase 1c instrumentation |
|
||||||
|
| `08a0867` | multi_coord_stress: fresh_workers two-tier index — fresh-resume now top-1 (3/3) |
|
||||||
|
| `5d49967` | multi_coord_stress: full Langfuse coverage — every phase + every call (111 observations) |
|
||||||
|
| `68d9e55` | shared: auto-emit Langfuse trace+span per HTTP request — closes OPEN #2 |
|
||||||
|
| `a2fa9a2` | scrum_review: pipe diff via temp files — fixes argv overflow on large bundles |
|
||||||
|
| (prep) | G5 cutover prep: `embed_parity` probe — Rust `/ai/embed` ↔ Go `/v1/embed` 5/5 cos=1.000 (both v1 and v2-moe). Verdict + drift catalog in `reports/cutover/SUMMARY.md`. Wire-format remap (`embeddings`/`vectors`, `dimensions`/`dimension`) is the only real cutover work; math is provably equivalent. |
|
||||||
|
| (probe) | Reality test real_001: 10 real-shape queries from `fill_events.parquet` through lift harness. 8/10 cold-pass top-1 = judge-best (substrate works on real distribution). Surfaced **same-client+city cross-role bleed** — Shape A boost from Forklift-Operator playbook landed on CNC-Operator query, demoting the cold-pass-correct worker. Findings: `reports/reality-tests/real_001_findings.md`. |
|
||||||
|
| (fix) | Cross-role gate: `Role` on `PlaybookEntry`, `QueryRole` on `SearchRequest`, gate fires in both `ApplyPlaybookBoost` + `InjectPlaybookMisses`. `roleEqual` handles case + plural. Backward-compat: empty role on either side = gate disabled (preserves lift suite + free-form callers). 5 new unit tests use exact real_001 distance + role values. Re-run real_002: bleed closed (Q#5 Pickers, Q#10 CNC Operator stay at cold-pass top-1; same-role lifts still fire). Closes OPEN #1. Findings: `reports/reality-tests/real_002_findings.md`. |
|
||||||
|
| (probe) | Reality test real_003: 40 queries (10 fill_events rows × 4 styles — `need` / `client_first` / `looking` / `shorthand`). Confirmed shorthand-vs-shorthand bleed is real: CNC Operator shorthand recording leaked `w-2404` onto Forklift Operator shorthand query (both empty role, gate disabled). Extended `extractRoleFromNeed` to handle `client_first` + `looking` patterns; shorthand stays empty (regex can't separate role from city without anchor). Re-run real_003b: bleed closed across all 4 styles in this dataset. 10 new sub-tests in `scripts/playbook_lift/main_test.go` lock the patterns + the documented shorthand limitation. Findings: `reports/reality-tests/real_003_findings.md`. |
|
||||||
|
| (fix) | LLM-based role extractor (real_004): `roleExtractor` struct with regex-first → qwen2.5 format=json fallback → per-process cache. Opt-in via `-llm-role-extract` flag + `LLM_ROLE_EXTRACT=1` env. Off-by-default preserves real_003b shipping config. 8 new tests including `TestRoleExtractor_ClosesCrossRoleShorthandBleed` — the load-bearing witness pairing with the matrix-side `TestInjectPlaybookMisses_RoleGateRejectsCrossRole` to prove the extraction-layer + gate-layer compose correctly on the exact real_003 failure mode. Findings: `reports/reality-tests/real_004_findings.md`. |
|
||||||
|
| (scrum) | 3-lineage scrum review on `7f2f112..0331288` (Opus + Kimi + Qwen3-coder via `scripts/scrum_review.sh`). Convergent finding (3/3): `roleNormalize` plural-stripper mangled non-plural-s tokens (Sales → Sale, Logistics → Logistic). **Fixed**: `nonPluralSWords` allowlist + `-ss` ending check + `strings.ToLower`/`TrimSpace` cleanup. New tests `TestRoleNormalize_NonPluralS` + `TestRoleEqual_NonPluralS` lock the edge cases. Kimi 2 BLOCKs were false positives (model-truncation artifacts per `feedback_cross_lineage_review.md`). Disposition: `reports/scrum/_evidence/2026-04-30/verdicts/role_gate_v1_disposition.md` (local). |
|
||||||
|
| (probe) | Negation reality test real_005: 5 explicit-negation queries ("NOT in Detroit", "excluding Cornerstone roster", etc.). Confirmed substrate has **zero negation handling** — cosine treats "NOT X" as "X" + noise. Judge IS the safety net (Q1/Q3/Q4 rated all top-10 results 1-2/5 — operator-visible honesty signal). **No code change needed**: production UI should handle exclusion via `ExcludeIDs` (already supported, added in multi-coord stress 200-worker swap), not via NL-negation. Findings: `reports/reality-tests/real_005_findings.md`. |
|
||||||
|
| (wire-up) | Multi-coord stress role wire-through: `Demand.Role` was already extracted at every call site (44 occurrences) but never threaded into matrix retrieve or playbook record. Cross-role gate was bypassed for the entire multi-coord harness. **Fixed** by extending `tracedSearch`, `matrixSearch`, and `playbookRecord` signatures with `role string` and updating all 14 call sites — passing `d.Role` (demand loops), `parsed.Role` (LLM-parsed inbox path), `warehouseDemand.Role` (swap path), `ev.Role` (reissue path), `""` (fresh-verify resume snippet — no clean role). Build + vet + tests green; multi-coord stress now honors role gate end-to-end. |
|
||||||
|
| (close-1) | **OPEN #1: vectord merge endpoint** — `POST /v1/vectors/index/{src}/merge` with body `{dest, clear_source}`. Idempotent on re-runs (existing-in-dest items skipped). New `Index.IDs()` snapshot method backs it; new `i.ids` tracker field is the canonical ID set (independent of meta map's nil-vs-{} sparseness). 4 cmd-level tests + 1 unit test. |
|
||||||
|
| (close-2) | **OPEN #2: distillation SFT export substrate** — `internal/distillation/sft_export.go`: `IsSftNever` predicate + `ListScoredRunFiles` (data/scored-runs/YYYY/MM/DD walk) + `LoadScoredRunsFromFile` + partial `ExportSft` that wires the firewall but leaves synthesis (instruction/input/response generation) as the next wave. Firewall pinning test fails if `SftNever` set changes without review. 5 new tests. The synthesis port remains on Rust at `scripts/distillation/export_sft.ts`. |
|
||||||
|
| (close-2 full) | **OPEN #2 fully ported** (2026-05-01): `SynthesizeSft` + `LoadEvidenceByRunID` + `buildInstruction` ported byte-for-byte from `scripts/distillation/export_sft.ts`. All 8 source-class instruction templates (scrum_reviews / mode_experiments / auto_apply / audits / observer_reviews / contract_analyses / outcomes / default) match Rust output exactly so a/b validation between runtimes can diff JSONL byte-for-byte. `ExportSft` writes to `data/distilled/sft/sft_export.jsonl`. 5 additional tests including per-source-class template verification, extraction-rejection, empty-text-rejection, context-assembly, end-to-end fixture write. |
|
||||||
|
| (close-2 lineage) | **Audit-baselines lineage ported** (2026-05-01): `internal/distillation/audit_baseline.go` mirrors Rust `audit_full.ts`'s LoadBaseline/AppendBaseline/buildDriftTable. `LoadLastBaseline` reads the most recent JSON line from `data/_kb/audit_baselines.jsonl`; `AppendBaseline` appends append-only with bufio. `BuildAuditDriftTable` flags drift `>20%` (configurable); zero-baseline and new-metric edge cases handled (no division-by-zero, no false-stable on zero→nonzero). `FormatAuditDriftTable` for stdout dumps. Generic on metric names so callers running both runtimes can pin Rust-compat names (`AuditBaselineRustCompat` constant lists them). 13 tests including last-line-wins, trailing-blank-tolerance, malformed-line-errors, threshold-boundary, zero-baseline-handling, sort-stability. |
|
||||||
|
| (scrum) | 3-lineage scrum on `434f466..0d4f033` (post_role_gate_v1). Convergent finding (Opus + Kimi): `DecodeIndex` lost nil-meta items across persistence. **Fixed** by bumping envelope version 1→2 with explicit `IDs []string` field; v1 envelopes still load via meta-key fallback. Opus-only real bugs also actioned: `handleMerge` non-`ErrIndexNotFound` nil-deref, `mathLog` dead wrapper removed, bubble sort → `sort.Slice`. False positives rejected after verification (Kimi rollback misreading + Opus stale-comment claim). 2 new regression tests lock the v2 round-trip + v1 backward-compat. Disposition: `reports/scrum/_evidence/2026-05-01/verdicts/post_role_gate_v1_disposition.md`. |
|
||||||
|
| (audit-full port) | **Audit-FULL pipeline** (phases 0/3/4) ported from `scripts/distillation/audit_full.ts`. `internal/distillation/audit_full.go` + `cmd/audit_full` CLI. 6 ported required-check classes; 4 phases (1, 2, 5, 6, 7) deferred — depend on broader Rust pieces (materializer / replay / run-summaries) not yet ported. **Cross-runtime byte-equal verdict on live data**: Go-side audit-full against `/home/profit/lakehouse` produced p3_*/p4_* metrics IDENTICAL to the last Rust-emitted `audit_baselines.jsonl` entry (all 8 metrics match: p3_accepted=386, p3_partial=132, p3_rejected=57, p3_human=480, p4_sft_rows=353, p4_rag_rows=448, p4_pref_pairs=83, p4_total_quarantined=1325). 6 new tests + the live-data probe captured in `reports/cutover/audit_full_go_vs_rust.md`. |
|
||||||
|
| (audit-full skips fixed) | **Phases 1/2/5/7 unskipped** (2026-05-01) — port reduced from 4 deferred phases to 1. **Phase 1**: invokes `go test ./internal/distillation/...` via exec.Command (Go equivalent of Rust's `bun test`). **Phase 2**: reads `data/evidence/` and tallies rows + tier-1 source hits as an observer (doesn't re-run the materializer; emits `p2_evidence_rows`/`p2_evidence_skips` metrics). **Phase 5**: reads `reports/distillation/{run_id}/summary.json` + 5 stage receipts; validates schema_version + run_hash sha256 + git_commit hex. **Phase 7**: reads `data/_kb/replay_runs.jsonl`; tail-row JSON parse check. Only **Phase 6** remains skipped (Rust `acceptance.ts` is a TS-only fixture harness; porting fixtures + invariant runner is its own ADR). Live-data probe: 12/12 required checks PASS, `p2_evidence_rows=1055` byte-equal to Rust `summary.json` `collect.records_out`. 6 new tests. |
|
||||||
|
| (lets-go) | **Persistent Go stack live** (2026-05-01). All 11 daemons (storaged/catalogd/ingestd/queryd/embedd/vectord/pathwayd/observerd/matrixd/gateway/chatd) up as long-running processes on :3110+:3211-:3220 → later moved to :4110+:4211-:4219+:3220 for smoke isolation. First time the Go side runs as production-shape daemons rather than per-harness transient processes. Brought up via `scripts/cutover/start_go_stack.sh`. Gateway proxies `/v1/embed` correctly to embedd; all 5 chatd providers loaded. **First Go-side entry written to `data/_kb/audit_baselines.jsonl`** (entry #8, git_commit=`ee2a40c`, golangLAKEHOUSE SHA distinguishable from Rust's `ca7375ea`); the longitudinal log now mixes runtimes. |
|
||||||
|
| (g5-slice) | **G5 cutover slice LIVE** (2026-05-01). First real Bun-frontend traffic reaching the Go substrate end-to-end. Bun mcp-server (`/home/profit/lakehouse/mcp-server/index.ts`) gains opt-in `/_go/*` pass-through to `$GO_LAKEHOUSE_URL` (set to `http://127.0.0.1:4110` via systemd drop-in). `/_go/v1/embed` returns nomic-embed-text-v2-moe vectors via Go embedd; `/_go/v1/matrix/search` returns 3/3 Forklift Operators against the persistent 200-worker corpus. Fully additive (no existing Bun tool modified) + fully reversible (unset env). `/api/*` (Rust gateway) path unchanged. See `reports/cutover/g5_first_slice_live.md`. |
|
||||||
|
| (close-3) | **OPEN #3: distribution drift via PSI** — `internal/drift/drift.go`: `ComputeDistributionDrift` returns Population Stability Index + verdict tier (stable < 0.10, minor 0.10–0.25, major ≥ 0.25). Equal-width bucketing over combined min/max range, epsilon-clamping for empty buckets, per-bucket breakdown for drilldown. 7 new tests including identical-is-stable, hard-shift-is-major, moderate-detected-not-stable, empty-inputs-safe, all-identical-safe, bucket-counts-conserved, num-buckets-clamping. |
|
||||||
|
| (close-4) | **OPEN #4: ops nice-to-haves** — (a) Real-time wall-clock for stress harness: per-phase elapsed time logged to stdout as it runs (`[stress] phase NAME starting (T+12.3s)` + `[stress] phase NAME done — 8.5s (T+20.8s)`); `Output.PhaseTimings` + `Output.TotalElapsedMs` written to JSON; (b) chatd fixture-mode S3 mock + (c) liberal-paraphrase calibration: not actioned — no fired trigger yet, would be speculative. Documented as deferred-until-need rather than ignored. |
|
||||||
|
| (close-bug) | **coder/hnsw v0.6.1 panic — REAL FIX landed** (2026-05-01 ~22:25). The 277884b multitier_100k run hit 96-98% fail on 2/6 scenarios from a v0.6.1 nil-deref (`layerNode.search`) that fires when the graph transitions through degenerate states post-Delete. Initial recover() guard caught panics but returned errors at the same rate. **Real fix**: lift the source-of-truth out of coder/hnsw — `i.vectors map[string][]float32` side store maintained alongside the graph, panic-safe `safeGraphAdd`/`safeGraphDelete` wrappers, `rebuildGraphLocked` reads from `i.vectors` (independent of graph state), warm-path Add falls back to rebuild on panic. Side effect: `i.ids` collapsed into `i.vectors` keys; `Len()` reads from `len(i.vectors)`. Memory cost: ~2x for vectors. Verification: 7 new regression tests in `index_test.go` (`TestAdd_PastThreshold_SustainedReAdd` reproduces the multitier shape — 64-entry index, 800 upserts, 0 errors), `just verify` PASS, multitier_100k re-run on persistent stack 19,622 scenarios / 0 failures across all 6 classes. p50 on previously-failing scenarios went 5ms (instant fail) → 551ms (real Add work — honest cost of correctness). |
|
||||||
|
| (perf-fix) | **Save coalescing — write-path lock contention closed** (2026-05-01 ~22:50). The panic fix exposed a second bottleneck: every successful Add called `Persistor.Save` synchronously, which takes the index RLock for `Encode` (~6MB JSON for 1942-entry × 768d) — blocking concurrent Add Lock acquisitions. 5min sustained run showed playbook scenario p50 climbing 551ms→1398ms as the index grew. **Fix**: `saveTask` per-index single-flight coalescer in `cmd/vectord/main.go` — `saveAfter` now triggers an async save; concurrent triggers during an in-flight save mark "pending" so N triggers collapse into ≤2 actual saves. RPO trade: Add returns OK before save completes (~1 save's worth of crash-loss exposure; same fail-open posture as ADR-005). Verification: 3 new tests in `cmd/vectord/main_test.go` (50-trigger pile-up → 2 saves; single → 1; error doesn't stall). Re-run: surge_fill_validate p50 1296ms→**47ms** (~28× faster), playbook_record_replay 1398ms→**385ms** (~3.6× faster), throughput 144→**668 scen/sec** at 0% fail. Restart-rehydrate verified — playbook_memory 4041 entries persisted to MinIO and round-tripped cleanly. |
|
||||||
|
|
||||||
|
Plus on Rust side (`8de94eb`, `3d06868`): qwen2.5 → qwen3.5:latest backport in active defaults; distillation acceptance reports regenerated (run_hash refresh, reproducibility property still holds).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RUNTIME CHEATSHEET
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify everything green
|
||||||
|
cd /home/profit/golangLAKEHOUSE
|
||||||
|
just verify # vet + tests + 9 core smokes (~31s)
|
||||||
|
just doctor # dep probe (go/gcc/minio/ollama/secrets)
|
||||||
|
|
||||||
|
# Boot the chat dispatcher (Phase 4)
|
||||||
|
nohup ./bin/chatd -config lakehouse.toml > /tmp/chatd.log 2>&1 & disown
|
||||||
|
nohup ./bin/gateway -config lakehouse.toml > /tmp/gateway.log 2>&1 & disown
|
||||||
|
curl -sf http://127.0.0.1:3110/v1/chat/providers | jq # all 5 providers should report true
|
||||||
|
|
||||||
|
# Test a chat call to each lineage
|
||||||
|
for m in "qwen3.5:latest" "opencode/claude-opus-4-7" "openrouter/moonshotai/kimi-k2-0905"; do
|
||||||
|
curl -sS -X POST http://127.0.0.1:3110/v1/chat \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"model\":\"$m\",\"messages\":[{\"role\":\"user\",\"content\":\"reply: OK\"}],\"max_tokens\":8}" \
|
||||||
|
| jq -c '{model,provider,content}'
|
||||||
|
done
|
||||||
|
|
||||||
|
# Run the scrum on a diff
|
||||||
|
./scripts/scrum_review.sh path/to/bundle.diff bundle_label
|
||||||
|
ls reports/scrum/_evidence/$(date +%Y-%m-%d)/verdicts/
|
||||||
|
|
||||||
|
# Domain smokes (not in `just verify`)
|
||||||
|
for s in chatd matrix observer pathway playbook relevance downgrade workflow; do
|
||||||
|
bash scripts/${s}_smoke.sh > /tmp/${s}.log 2>&1 && echo "$s ✓" || echo "$s ✗"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VISION — what we're actually building
|
||||||
|
|
||||||
|
J's framing (canonical at `/root/.claude/projects/-home-profit/memory/project_small_model_pipeline_vision.md`): a small-model-driven autonomous pipeline that gets better with each run. Frontier APIs (Opus, Kimi, GPT-5) are too expensive + rate-limited for the inner loop — they live in audit/oversight via `frontier_*` tier. The hot path runs on local `qwen3.5:latest` given:
|
||||||
|
|
||||||
|
1. **Pathway memory** — what we tried before, how it went (Mem0 substrate ✓)
|
||||||
|
2. **Matrix indexer** — multi-corpus retrieve+merge giving the small model the right slice for this task (5/5 components ✓)
|
||||||
|
3. **Observer** — watches each run, refines configs (not prompts) toward good pathways
|
||||||
|
|
||||||
|
Successful runs get **rated and distilled back into the playbook**. Each iteration the playbook gets denser, runs get cheaper, results get better. **Drift** in the distilled playbook is a measured signal, not vibes.
|
||||||
|
|
||||||
|
**The single load-bearing gate:** *"the playbook + matrix indexer must give the results we're looking for."* Throughput, scaling, code elegance are all secondary. The `playbook_lift` reality test is the regression gate before Enterprise cutover (where real contracts + live profile updates land).
|
||||||
|
|
||||||
|
When evaluating any Go workstream, ask: which of the 5 loops does this advance? Strong workstreams advance ≥1; weak workstreams sit in infra-for-its-own-sake.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SIBLING TOOLS (separate repos, intentional integration target later)
|
||||||
|
|
||||||
|
**`local-review-harness`** at `git.agentview.dev/profit/local-review-harness` (also SMB-mounted at `/home/profit/share/local-review-harness-full-md/`). Local-first code review harness — 12 evidence-bearing static analyzers, Scrum-style reports, no cloud deps. Phase A + B (MVP) shipped 2026-04-30. Phases C–E (Ollama LLM review, validation, memory) pending.
|
||||||
|
|
||||||
|
**Cross-pollination plan when both stabilize:**
|
||||||
|
- Replace harness's `internal/llm/ollama.go` with a chatd `/v1/chat` client → frontier judges via config toggle
|
||||||
|
- Feed harness findings into Lakehouse pathway memory as a drift signal
|
||||||
|
- Treat harness's `.memory/known-risks.json` as a matrix-indexer corpus
|
||||||
|
|
||||||
|
Detail at `docs/SPEC.md` §3.10. Don't re-port harness functionality into Lakehouse-Go — the standalone tool is the design.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// audit_full — Go-side audit-full runner. Calls into
|
||||||
|
// internal/distillation.RunAuditFull, dumps the Markdown report to
|
||||||
|
// stdout (or a file), and optionally appends an AuditBaseline entry
|
||||||
|
// to data/_kb/audit_baselines.jsonl for the longitudinal log.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// audit_full # report only
|
||||||
|
// audit_full -root /home/profit/lakehouse # custom root
|
||||||
|
// audit_full -append-baseline # also append to audit_baselines.jsonl
|
||||||
|
// audit_full -out reports/distillation/run.md # write report file
|
||||||
|
//
|
||||||
|
// Designed to live alongside the Rust scripts/distillation/audit_full.ts
|
||||||
|
// — operators can run either runtime against the same root and the
|
||||||
|
// audit_baselines.jsonl entries are interchangeable.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/distillation"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := flag.String("root", "", "lakehouse data root (defaults to $LH_DISTILL_ROOT or /home/profit/lakehouse)")
|
||||||
|
out := flag.String("out", "", "write Markdown report to this path (default: stdout)")
|
||||||
|
appendBaseline := flag.Bool("append-baseline", false, "append an AuditBaseline entry to data/_kb/audit_baselines.jsonl after the run")
|
||||||
|
jsonOut := flag.Bool("json", false, "emit the full PhaseCheckReport as JSON instead of Markdown")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
gitHEAD := resolveGitHEAD()
|
||||||
|
report := distillation.RunAuditFull(distillation.AuditFullOptions{
|
||||||
|
Root: *root,
|
||||||
|
GitHEAD: gitHEAD,
|
||||||
|
})
|
||||||
|
|
||||||
|
var body []byte
|
||||||
|
if *jsonOut {
|
||||||
|
body = mustJSON(report)
|
||||||
|
} else {
|
||||||
|
body = []byte(distillation.FormatAuditFullReport(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
if *out == "" {
|
||||||
|
_, _ = os.Stdout.Write(body)
|
||||||
|
} else {
|
||||||
|
if err := os.WriteFile(*out, body, 0o644); err != nil {
|
||||||
|
log.Fatalf("write %s: %v", *out, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "wrote %s (%d bytes)\n", *out, len(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if *appendBaseline {
|
||||||
|
// Resolve the same path the Rust pipeline uses so both
|
||||||
|
// runtimes share the audit_baselines.jsonl log.
|
||||||
|
resolvedRoot := *root
|
||||||
|
if resolvedRoot == "" {
|
||||||
|
if env := os.Getenv("LH_DISTILL_ROOT"); env != "" {
|
||||||
|
resolvedRoot = env
|
||||||
|
} else {
|
||||||
|
resolvedRoot = "/home/profit/lakehouse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bp := distillation.DefaultBaselinePath(resolvedRoot)
|
||||||
|
err := distillation.AppendBaseline(bp, distillation.AuditBaseline{
|
||||||
|
RecordedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
GitCommit: gitHEAD,
|
||||||
|
Metrics: report.Metrics,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("append baseline: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "appended baseline to %s\n", bp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.Failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveGitHEAD returns the current commit SHA if the Go repo is a
|
||||||
|
// git checkout. Falls back to "" rather than failing — the audit
|
||||||
|
// runs even on a fresh clone without git.
|
||||||
|
func resolveGitHEAD() string {
|
||||||
|
cmd := exec.Command("git", "rev-parse", "HEAD")
|
||||||
|
bs, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(bs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(v any) []byte {
|
||||||
|
bs, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("json marshal: %v", err)
|
||||||
|
}
|
||||||
|
return append(bs, '\n')
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ func main() {
|
|||||||
|
|
||||||
h := newHandlers(registry)
|
h := newHandlers(registry)
|
||||||
|
|
||||||
if err := shared.Run("catalogd", cfg.Catalogd.Bind, h.register); err != nil {
|
if err := shared.Run("catalogd", cfg.Catalogd.Bind, h.register, cfg.Auth); err != nil {
|
||||||
slog.Error("server", "err", err)
|
slog.Error("server", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/catalogd"
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/storeclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Closes R-005 for catalogd: cmd-level tests for route mounting,
|
||||||
|
// body-cap rejection, malformed JSON handling, and the decode-error
|
||||||
|
// paths in handleRegister. Deeper Registry semantics live in
|
||||||
|
// internal/catalogd/registry_test.go.
|
||||||
|
|
||||||
|
func newTestHandlers(t *testing.T) (*handlers, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
// Stub storaged so the registry can hydrate (it needs nothing
|
||||||
|
// initially). Empty server = 404 on any GET; that's fine for
|
||||||
|
// these tests because we don't exercise storaged paths here.
|
||||||
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
t.Cleanup(stub.Close)
|
||||||
|
|
||||||
|
store := storeclient.New(stub.URL)
|
||||||
|
reg := catalogd.NewRegistry(store)
|
||||||
|
return newHandlers(reg), stub
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountedRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutesMounted(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
r := mountedRouter(h)
|
||||||
|
want := map[string]string{
|
||||||
|
"POST /catalog/register": "register endpoint",
|
||||||
|
"GET /catalog/manifest/*": "manifest endpoint",
|
||||||
|
"GET /catalog/list": "list endpoint",
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||||
|
got[method+" "+route] = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
for sig := range want {
|
||||||
|
if !got[sig] {
|
||||||
|
t.Errorf("expected route %q mounted; got %v", sig, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRegister_BodyTooLarge(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
r := mountedRouter(h)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// 5 MiB body — over the 4 MiB cap.
|
||||||
|
big := bytes.Repeat([]byte("x"), 5<<20)
|
||||||
|
resp, err := http.Post(srv.URL+"/catalog/register", "application/json", bytes.NewReader(big))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
// MaxBytesReader trips during JSON decode → 400 with "body too large"
|
||||||
|
// in the message, OR 413 if Content-Length up-front cap is added.
|
||||||
|
// Today the path returns 400 via decode error; lock that contract.
|
||||||
|
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||||
|
t.Errorf("expected 4xx on oversize body, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRegister_MalformedJSON(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
r := mountedRouter(h)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/catalog/register",
|
||||||
|
"application/json", strings.NewReader("not json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on malformed JSON, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRegister_EmptyName_400(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
r := mountedRouter(h)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
body := `{"name":"","schema_fingerprint":"sha256:x","objects":[{"key":"k","size":1}]}`
|
||||||
|
resp, err := http.Post(srv.URL+"/catalog/register", "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on empty name, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetManifest_404(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
r := mountedRouter(h)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/catalog/manifest/nonexistent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 for missing manifest, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleList_EmptyShape(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
r := mountedRouter(h)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/catalog/list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// chatd is the LLM chat dispatcher service (Phase 4 — small-model
|
||||||
|
// pipeline tier abstraction). Routes POST /chat to the right
|
||||||
|
// provider based on the model-name prefix or :cloud suffix:
|
||||||
|
//
|
||||||
|
// ollama/<m> → local Ollama (no auth)
|
||||||
|
// ollama_cloud/<m> → Ollama Cloud (Bearer auth)
|
||||||
|
// <m>:cloud → Ollama Cloud (suffix variant)
|
||||||
|
// openrouter/<v>/<m> → OpenRouter (Bearer auth)
|
||||||
|
// opencode/<m> → OpenCode unified Zen+Go (Bearer auth)
|
||||||
|
// kimi/<m> → Kimi For Coding (Bearer auth)
|
||||||
|
// bare names → local Ollama (default)
|
||||||
|
//
|
||||||
|
// Provider keys come from env vars (or /etc/lakehouse/<provider>.env
|
||||||
|
// fallback files). Providers with empty keys stay unregistered, so
|
||||||
|
// requests for them 404 cleanly instead of 503-ing at call time.
|
||||||
|
//
|
||||||
|
// Routes:
|
||||||
|
//
|
||||||
|
// POST /chat — dispatch a chat request to the resolved provider
|
||||||
|
// GET /providers — list registered providers (telemetry / health)
|
||||||
|
// GET /health — readiness (always 200 — sub-providers are
|
||||||
|
// independently checked via /providers)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/chat"
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxRequestBytes = 4 << 20 // 4 MiB cap on /chat bodies
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "lakehouse.toml", "path to TOML config")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := shared.LoadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(cfg.Chatd.TimeoutSecs) * time.Second
|
||||||
|
registry := chat.BuildRegistry(chat.BuilderInput{
|
||||||
|
OllamaURL: cfg.Chatd.OllamaURL,
|
||||||
|
OllamaCloudKey: chat.ResolveKey(cfg.Chatd.OllamaCloudKeyEnv, cfg.Chatd.OllamaCloudKeyFile),
|
||||||
|
OpenRouterKey: chat.ResolveKey(cfg.Chatd.OpenRouterKeyEnv, cfg.Chatd.OpenRouterKeyFile),
|
||||||
|
OpenCodeKey: chat.ResolveKey(cfg.Chatd.OpenCodeKeyEnv, cfg.Chatd.OpenCodeKeyFile),
|
||||||
|
KimiKey: chat.ResolveKey(cfg.Chatd.KimiKeyEnv, cfg.Chatd.KimiKeyFile),
|
||||||
|
Timeout: timeout,
|
||||||
|
})
|
||||||
|
|
||||||
|
h := &handlers{registry: registry}
|
||||||
|
if err := shared.Run("chatd", cfg.Chatd.Bind, h.register, cfg.Auth); err != nil {
|
||||||
|
slog.Error("server", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type handlers struct {
|
||||||
|
registry *chat.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) register(r chi.Router) {
|
||||||
|
// Routes mirror what the gateway proxies: /v1/chat → /chat (POST)
|
||||||
|
// and /v1/chat/providers → /chat/providers (GET). Keeping providers
|
||||||
|
// under /chat/ avoids a separate /providers root route that would
|
||||||
|
// need its own gateway proxy entry.
|
||||||
|
r.Post("/chat", h.handleChat)
|
||||||
|
r.Get("/chat/providers", h.handleProviders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
var req chat.Request
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Model == "" || len(req.Messages) == 0 {
|
||||||
|
http.Error(w, "model and messages are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.registry.Chat(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleProviders lists registered providers + per-provider Available()
|
||||||
|
// status. Phase 4 scrum fix B-2: looks up by name directly, skipping
|
||||||
|
// the prior synthetic-model-name route through Resolve. The endpoint
|
||||||
|
// reports registry membership, not routing rules — the latter is
|
||||||
|
// covered by /v1/chat itself.
|
||||||
|
func (h *handlers) handleProviders(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
names := h.registry.Names()
|
||||||
|
statuses := make(map[string]bool, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
statuses[n] = h.registry.Available(n)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"providers": statuses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeChatError maps chat sentinel errors to HTTP status codes.
|
||||||
|
// Unknown errors map to 500.
|
||||||
|
func writeChatError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, chat.ErrProviderNotFound):
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
case errors.Is(err, chat.ErrProviderDisabled):
|
||||||
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
case errors.Is(err, chat.ErrUpstream):
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
case errors.Is(err, chat.ErrTimeout):
|
||||||
|
http.Error(w, err.Error(), http.StatusGatewayTimeout)
|
||||||
|
default:
|
||||||
|
slog.Error("chat", "err", err)
|
||||||
|
http.Error(w, "internal", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||||
|
slog.Error("encode", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-3
@@ -41,11 +41,22 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &handlers{
|
// Wrap the upstream provider in an LRU cache so repeat queries
|
||||||
provider: embed.NewOllama(cfg.Embedd.ProviderURL, cfg.Embedd.DefaultModel),
|
// (the staffing co-pilot replays many of the same texts) bypass
|
||||||
|
// the ~50ms Ollama round-trip. Cache size 0 = pass-through.
|
||||||
|
base := embed.NewOllama(cfg.Embedd.ProviderURL, cfg.Embedd.DefaultModel)
|
||||||
|
cached, err := embed.NewCachedProvider(base, cfg.Embedd.DefaultModel, cfg.Embedd.CacheSize)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("embed cache", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
slog.Info("embed cache",
|
||||||
|
"size", cfg.Embedd.CacheSize,
|
||||||
|
"default_model", cfg.Embedd.DefaultModel,
|
||||||
|
"enabled", cfg.Embedd.CacheSize > 0)
|
||||||
|
h := &handlers{provider: cached, cache: cached}
|
||||||
|
|
||||||
if err := shared.Run("embedd", cfg.Embedd.Bind, h.register); err != nil {
|
if err := shared.Run("embedd", cfg.Embedd.Bind, h.register, cfg.Auth); err != nil {
|
||||||
slog.Error("server", "err", err)
|
slog.Error("server", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -53,10 +64,36 @@ func main() {
|
|||||||
|
|
||||||
type handlers struct {
|
type handlers struct {
|
||||||
provider embed.Provider
|
provider embed.Provider
|
||||||
|
// cache is the same instance as provider when caching is enabled,
|
||||||
|
// kept as a typed pointer so /v1/embed/stats can expose hit-rate
|
||||||
|
// without type-asserting through the Provider interface. nil when
|
||||||
|
// CacheSize=0 (pass-through mode).
|
||||||
|
cache *embed.CachedProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handlers) register(r chi.Router) {
|
func (h *handlers) register(r chi.Router) {
|
||||||
r.Post("/embed", h.handleEmbed)
|
r.Post("/embed", h.handleEmbed)
|
||||||
|
r.Get("/embed/stats", h.handleStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStats reports cache hits/misses + hit rate + size. Operators
|
||||||
|
// use this to confirm the cache is doing its job (high hit rate) or
|
||||||
|
// is sized wrong (low hit rate + many misses on a workload that
|
||||||
|
// should have repeats).
|
||||||
|
func (h *handlers) handleStats(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if h.cache == nil {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"enabled": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hits, misses := h.cache.Stats()
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"hits": hits,
|
||||||
|
"misses": misses,
|
||||||
|
"hit_rate": h.cache.HitRate(),
|
||||||
|
"size": h.cache.Len(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// embedRequest is the POST /embed body. Texts is the list to
|
// embedRequest is the POST /embed body. Texts is the list to
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Closes R-005 for embedd: cmd-level tests for the /embed handler's
|
||||||
|
// decode + validation paths (empty texts → 400, body cap → 413,
|
||||||
|
// upstream error → 502). Provider semantics live in
|
||||||
|
// internal/embed/ollama_test.go.
|
||||||
|
|
||||||
|
// stubProvider implements embed.Provider with deterministic stubs.
|
||||||
|
type stubProvider struct {
|
||||||
|
result embed.Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubProvider) Embed(_ context.Context, _ []string, _ string) (embed.Result, error) {
|
||||||
|
return s.result, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountWithProvider(p embed.Provider) chi.Router {
|
||||||
|
h := &handlers{provider: p}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutesMounted(t *testing.T) {
|
||||||
|
r := mountWithProvider(&stubProvider{})
|
||||||
|
found := false
|
||||||
|
chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||||
|
if method == "POST" && route == "/embed" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if !found {
|
||||||
|
t.Error("POST /embed not mounted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEmbed_BodyTooLarge(t *testing.T) {
|
||||||
|
// MaxBytesReader trips during JSON decode. Depending on whether
|
||||||
|
// the decoder unwrapping surfaces MaxBytesError or wraps it as a
|
||||||
|
// generic decode error, the response is either 413 or 400. Both
|
||||||
|
// are valid "client error, fails loud" contracts; the harness's
|
||||||
|
// proof_assert_status_4xx covers either at the integration level.
|
||||||
|
r := mountWithProvider(&stubProvider{})
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
big := bytes.Repeat([]byte("x"), maxRequestBytes+(1<<20))
|
||||||
|
resp, err := http.Post(srv.URL+"/embed", "application/json", bytes.NewReader(big))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||||
|
t.Errorf("expected 4xx on oversize, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEmbed_MalformedJSON_400(t *testing.T) {
|
||||||
|
r := mountWithProvider(&stubProvider{})
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/embed", "application/json", strings.NewReader("not json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on malformed, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEmbed_EmptyTextRejected_400(t *testing.T) {
|
||||||
|
// Per scrum O-W3 (Opus): reject empty strings up front.
|
||||||
|
r := mountWithProvider(&stubProvider{})
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/embed", "application/json",
|
||||||
|
strings.NewReader(`{"texts":["valid",""]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on empty text in batch, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEmbed_UpstreamError_502(t *testing.T) {
|
||||||
|
// Provider returns a generic error → handler maps to 502 (the
|
||||||
|
// "embedding backend was wrong" case, distinct from 400 = your
|
||||||
|
// input was wrong).
|
||||||
|
r := mountWithProvider(&stubProvider{err: errors.New("ollama is down")})
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/embed", "application/json",
|
||||||
|
strings.NewReader(`{"texts":["hello"]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadGateway {
|
||||||
|
t.Errorf("expected 502 on provider error, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEmbed_HappyPath_ProviderEcho(t *testing.T) {
|
||||||
|
stub := &stubProvider{result: embed.Result{
|
||||||
|
Model: "test-model",
|
||||||
|
Dimension: 3,
|
||||||
|
Vectors: [][]float32{{0.1, 0.2, 0.3}},
|
||||||
|
}}
|
||||||
|
r := mountWithProvider(stub)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/embed", "application/json",
|
||||||
|
strings.NewReader(`{"texts":["hello"],"model":"test-model"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 happy path, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsEndpoint_CacheDisabled(t *testing.T) {
|
||||||
|
r := mountWithProvider(&stubProvider{})
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/embed/stats")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(getBody(t, resp), `"enabled":false`) {
|
||||||
|
t.Errorf("expected enabled:false when no cache wired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsEndpoint_CacheEnabled_TracksHitsAndMisses(t *testing.T) {
|
||||||
|
stub := &stubProvider{result: embed.Result{
|
||||||
|
Model: "test-model",
|
||||||
|
Dimension: 3,
|
||||||
|
Vectors: [][]float32{{0.1, 0.2, 0.3}},
|
||||||
|
}}
|
||||||
|
cache, err := embed.NewCachedProvider(stub, "test-model", 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCachedProvider: %v", err)
|
||||||
|
}
|
||||||
|
h := &handlers{provider: cache, cache: cache}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// First call → miss.
|
||||||
|
http.Post(srv.URL+"/embed", "application/json",
|
||||||
|
strings.NewReader(`{"texts":["hello"],"model":"test-model"}`))
|
||||||
|
// Second call same text → hit.
|
||||||
|
http.Post(srv.URL+"/embed", "application/json",
|
||||||
|
strings.NewReader(`{"texts":["hello"],"model":"test-model"}`))
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/embed/stats")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stats GET: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body := getBody(t, resp)
|
||||||
|
if !strings.Contains(body, `"enabled":true`) {
|
||||||
|
t.Errorf("expected enabled:true, body=%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"hits":1`) {
|
||||||
|
t.Errorf("expected hits:1, body=%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"misses":1`) {
|
||||||
|
t.Errorf("expected misses:1, body=%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBody(t *testing.T, resp *http.Response) string {
|
||||||
|
t.Helper()
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
n, _ := resp.Body.Read(buf)
|
||||||
|
return string(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItoa(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in int
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
{0, "0"},
|
||||||
|
{1, "1"},
|
||||||
|
{42, "42"},
|
||||||
|
{1000, "1000"},
|
||||||
|
{99, "99"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := itoa(tc.in); got != tc.out {
|
||||||
|
t.Errorf("itoa(%d) = %q, want %q", tc.in, got, tc.out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// fake_ollama is a minimal Ollama-API-compatible fake for proof harness
|
||||||
|
// fixture-mode smokes (R-006 partial). Implements just enough of the
|
||||||
|
// Ollama API surface for embedd to drive end-to-end without a real
|
||||||
|
// Ollama installation:
|
||||||
|
//
|
||||||
|
// GET /api/tags — returns a fixed model list including
|
||||||
|
// nomic-embed-text:latest
|
||||||
|
// POST /api/embeddings — returns a deterministic dim-D vector
|
||||||
|
// derived from sha256(prompt). Same prompt
|
||||||
|
// → bit-identical vector across runs.
|
||||||
|
//
|
||||||
|
// Vectors are NOT semantically meaningful (the value of similarity
|
||||||
|
// search against these is undefined). The fake is for proving the
|
||||||
|
// EMBED CONTRACT — dimension echo, response shape, status codes —
|
||||||
|
// not for proving real semantic ranking. That requires real Ollama.
|
||||||
|
//
|
||||||
|
// Why this exists: the proof harness's contract tier already runs
|
||||||
|
// against real Ollama (when present). For CI / fresh-clone reviewers
|
||||||
|
// without Ollama, this fake unblocks the chain.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// bin/fake_ollama --bind 127.0.0.1:11435 --dim 768
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
bind := flag.String("bind", "127.0.0.1:11435", "bind addr")
|
||||||
|
dim := flag.Int("dim", 768, "embedding dimension to return")
|
||||||
|
model := flag.String("model", "nomic-embed-text", "model name to echo back")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/tags", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"models": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": *model + ":latest",
|
||||||
|
"model": *model + ":latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "decode: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Reject unknown models so embedd's bad-model→502 contract
|
||||||
|
// path is exercisable. The fake recognizes the configured
|
||||||
|
// model name only.
|
||||||
|
if req.Model != "" && req.Model != *model && req.Model != *model+":latest" {
|
||||||
|
http.Error(w, fmt.Sprintf("model %q not found", req.Model), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vec := deterministicVector(req.Prompt, *dim)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"embedding": vec,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok","service":"fake_ollama"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
slog.Info("fake_ollama starting", "bind", *bind, "dim", *dim, "model", *model)
|
||||||
|
srv := &http.Server{Addr: *bind, Handler: mux}
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("fake_ollama serve", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deterministicVector returns a fixed dim-d float64 vector derived
|
||||||
|
// from sha256(prompt). Same prompt → same vector across runs and
|
||||||
|
// across machines, so smoke assertions can compare to fixtures.
|
||||||
|
func deterministicVector(prompt string, dim int) []float64 {
|
||||||
|
h := sha256.Sum256([]byte(prompt))
|
||||||
|
vec := make([]float64, dim)
|
||||||
|
for i := range vec {
|
||||||
|
// Spread the 32 hash bytes across `dim` positions; map to
|
||||||
|
// [-1, 1] so cosine distance is well-defined and the result
|
||||||
|
// looks vaguely like a real embedding.
|
||||||
|
b := h[i%len(h)]
|
||||||
|
vec[i] = (float64(b) - 128.0) / 128.0
|
||||||
|
}
|
||||||
|
return vec
|
||||||
|
}
|
||||||
+29
-1
@@ -44,6 +44,11 @@ func main() {
|
|||||||
"queryd_url": cfg.Gateway.QuerydURL,
|
"queryd_url": cfg.Gateway.QuerydURL,
|
||||||
"vectord_url": cfg.Gateway.VectordURL,
|
"vectord_url": cfg.Gateway.VectordURL,
|
||||||
"embedd_url": cfg.Gateway.EmbeddURL,
|
"embedd_url": cfg.Gateway.EmbeddURL,
|
||||||
|
"pathwayd_url": cfg.Gateway.PathwaydURL,
|
||||||
|
"matrixd_url": cfg.Gateway.MatrixdURL,
|
||||||
|
"observerd_url": cfg.Gateway.ObserverdURL,
|
||||||
|
"chatd_url": cfg.Gateway.ChatdURL,
|
||||||
|
"validatord_url": cfg.Gateway.ValidatordURL,
|
||||||
}
|
}
|
||||||
for k, v := range upstreams {
|
for k, v := range upstreams {
|
||||||
if v == "" {
|
if v == "" {
|
||||||
@@ -63,6 +68,11 @@ func main() {
|
|||||||
querydURL := mustParseUpstream("queryd_url", cfg.Gateway.QuerydURL)
|
querydURL := mustParseUpstream("queryd_url", cfg.Gateway.QuerydURL)
|
||||||
vectordURL := mustParseUpstream("vectord_url", cfg.Gateway.VectordURL)
|
vectordURL := mustParseUpstream("vectord_url", cfg.Gateway.VectordURL)
|
||||||
embeddURL := mustParseUpstream("embedd_url", cfg.Gateway.EmbeddURL)
|
embeddURL := mustParseUpstream("embedd_url", cfg.Gateway.EmbeddURL)
|
||||||
|
pathwaydURL := mustParseUpstream("pathwayd_url", cfg.Gateway.PathwaydURL)
|
||||||
|
matrixdURL := mustParseUpstream("matrixd_url", cfg.Gateway.MatrixdURL)
|
||||||
|
observerdURL := mustParseUpstream("observerd_url", cfg.Gateway.ObserverdURL)
|
||||||
|
chatdURL := mustParseUpstream("chatd_url", cfg.Gateway.ChatdURL)
|
||||||
|
validatordURL := mustParseUpstream("validatord_url", cfg.Gateway.ValidatordURL)
|
||||||
|
|
||||||
storagedProxy := gateway.NewProxyHandler(storagedURL)
|
storagedProxy := gateway.NewProxyHandler(storagedURL)
|
||||||
catalogdProxy := gateway.NewProxyHandler(catalogdURL)
|
catalogdProxy := gateway.NewProxyHandler(catalogdURL)
|
||||||
@@ -70,8 +80,14 @@ func main() {
|
|||||||
querydProxy := gateway.NewProxyHandler(querydURL)
|
querydProxy := gateway.NewProxyHandler(querydURL)
|
||||||
vectordProxy := gateway.NewProxyHandler(vectordURL)
|
vectordProxy := gateway.NewProxyHandler(vectordURL)
|
||||||
embeddProxy := gateway.NewProxyHandler(embeddURL)
|
embeddProxy := gateway.NewProxyHandler(embeddURL)
|
||||||
|
pathwaydProxy := gateway.NewProxyHandler(pathwaydURL)
|
||||||
|
matrixdProxy := gateway.NewProxyHandler(matrixdURL)
|
||||||
|
observerdProxy := gateway.NewProxyHandler(observerdURL)
|
||||||
|
chatdProxy := gateway.NewProxyHandler(chatdURL)
|
||||||
|
validatordProxy := gateway.NewProxyHandler(validatordURL)
|
||||||
|
|
||||||
if err := shared.Run("gateway", cfg.Gateway.Bind, func(r chi.Router) {
|
if err := shared.Run("gateway", cfg.Gateway.Bind, func(r chi.Router) {
|
||||||
|
|
||||||
// Storage / catalog have multi-segment paths under their
|
// Storage / catalog have multi-segment paths under their
|
||||||
// prefix (e.g. /v1/storage/get/<key>). chi's `*` wildcard
|
// prefix (e.g. /v1/storage/get/<key>). chi's `*` wildcard
|
||||||
// captures the rest of the path.
|
// captures the rest of the path.
|
||||||
@@ -87,7 +103,19 @@ func main() {
|
|||||||
r.Handle("/v1/vectors/*", vectordProxy)
|
r.Handle("/v1/vectors/*", vectordProxy)
|
||||||
// Embedding service — /v1/embed
|
// Embedding service — /v1/embed
|
||||||
r.Handle("/v1/embed", embeddProxy)
|
r.Handle("/v1/embed", embeddProxy)
|
||||||
}); err != nil {
|
// Pathway memory — /v1/pathway/*
|
||||||
|
r.Handle("/v1/pathway/*", pathwaydProxy)
|
||||||
|
// Matrix indexer — /v1/matrix/* (multi-corpus retrieve+merge per SPEC §3.4)
|
||||||
|
r.Handle("/v1/matrix/*", matrixdProxy)
|
||||||
|
// Observer — /v1/observer/* (autonomous-iteration witness loop)
|
||||||
|
r.Handle("/v1/observer/*", observerdProxy)
|
||||||
|
// Chat — /v1/chat (LLM dispatcher) + /v1/chat/providers
|
||||||
|
r.Handle("/v1/chat", chatdProxy)
|
||||||
|
r.Handle("/v1/chat/*", chatdProxy)
|
||||||
|
// Validator — /v1/validate (single-shot) + /v1/iterate (loop)
|
||||||
|
r.Handle("/v1/validate", validatordProxy)
|
||||||
|
r.Handle("/v1/iterate", validatordProxy)
|
||||||
|
}, cfg.Auth); err != nil {
|
||||||
slog.Error("server", "err", err)
|
slog.Error("server", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Closes R-005 for gateway: cmd-level test for mustParseUpstream.
|
||||||
|
// The proxy mounts themselves are exercised end-to-end by the
|
||||||
|
// proof harness's GOLAKE-003 case (gateway proxy passthrough).
|
||||||
|
//
|
||||||
|
// mustParseUpstream calls os.Exit on bad input — testing it directly
|
||||||
|
// would kill the test process. The standard Go pattern for testing
|
||||||
|
// os.Exit-calling code: re-exec the test binary with a flag and
|
||||||
|
// observe the subprocess exit status. We exercise the helper that
|
||||||
|
// way for the failure paths and inline-check the success path.
|
||||||
|
|
||||||
|
func TestMustParseUpstream_HappyPaths(t *testing.T) {
|
||||||
|
// Success paths can be exercised inline — only failure exits.
|
||||||
|
cases := []string{
|
||||||
|
"http://127.0.0.1:3211",
|
||||||
|
"https://example.com:443",
|
||||||
|
"http://catalogd:3212",
|
||||||
|
}
|
||||||
|
for _, raw := range cases {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
u := mustParseUpstream("test", raw)
|
||||||
|
if u.Scheme == "" || u.Host == "" {
|
||||||
|
t.Errorf("mustParseUpstream(%q) returned empty scheme/host: %+v", raw, u)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustParseUpstream_FailureExits(t *testing.T) {
|
||||||
|
if os.Getenv("GATEWAY_TEST_EXIT") == "1" {
|
||||||
|
// Subprocess: invoke mustParseUpstream with a bad value;
|
||||||
|
// expect os.Exit(1). url.Parse is permissive — schemes can
|
||||||
|
// be missing without a parse error — so the assertion in
|
||||||
|
// mustParseUpstream catches the empty-Host case.
|
||||||
|
mustParseUpstream("storaged_url", "127.0.0.1:3211")
|
||||||
|
// If we reach here, the function failed to fail.
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMustParseUpstream_FailureExits")
|
||||||
|
cmd.Env = append(os.Environ(), "GATEWAY_TEST_EXIT=1")
|
||||||
|
err := cmd.Run()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected subprocess to exit non-zero on bad upstream URL")
|
||||||
|
}
|
||||||
|
exitErr, ok := err.(*exec.ExitError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if exitErr.ExitCode() == 0 {
|
||||||
|
t.Fatal("subprocess returned 0 — mustParseUpstream did not fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustParseUpstream_GarbageInput_Exits(t *testing.T) {
|
||||||
|
if os.Getenv("GATEWAY_TEST_EXIT_GARBAGE") == "1" {
|
||||||
|
mustParseUpstream("queryd_url", "https://%zz")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMustParseUpstream_GarbageInput_Exits")
|
||||||
|
cmd.Env = append(os.Environ(), "GATEWAY_TEST_EXIT_GARBAGE=1")
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected subprocess to exit non-zero on garbage URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpstreamConfigKeys documents the upstream config field names
|
||||||
|
// the gateway expects. A future refactor that renames a field would
|
||||||
|
// fail this test; operators eyeballing systemctl status see the
|
||||||
|
// failure before traffic does.
|
||||||
|
func TestUpstreamConfigKeys_DocumentedShape(t *testing.T) {
|
||||||
|
// This test is shape documentation. main() iterates a map with
|
||||||
|
// these exact keys; if any are renamed, all gateway deployments
|
||||||
|
// silently break.
|
||||||
|
expected := []string{
|
||||||
|
"storaged_url",
|
||||||
|
"catalogd_url",
|
||||||
|
"ingestd_url",
|
||||||
|
"queryd_url",
|
||||||
|
"vectord_url",
|
||||||
|
"embedd_url",
|
||||||
|
}
|
||||||
|
for _, k := range expected {
|
||||||
|
if !strings.HasSuffix(k, "_url") {
|
||||||
|
t.Errorf("upstream key %q does not end in _url — convention break", k)
|
||||||
|
}
|
||||||
|
if _, err := url.Parse("http://" + k); err != nil {
|
||||||
|
t.Errorf("key %q failed url-test parse: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -65,7 +65,7 @@ func main() {
|
|||||||
maxBytes: maxBytes,
|
maxBytes: maxBytes,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := shared.Run("ingestd", cfg.Ingestd.Bind, h.register); err != nil {
|
if err := shared.Run("ingestd", cfg.Ingestd.Bind, h.register, cfg.Auth); err != nil {
|
||||||
slog.Error("server", "err", err)
|
slog.Error("server", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/catalogclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Closes R-005 for ingestd: cmd-level tests for the cmd-shape
|
||||||
|
// of /ingest — name query param, body cap, multipart parsing,
|
||||||
|
// missing form file. CSV→Parquet logic is tested in internal/ingestd.
|
||||||
|
|
||||||
|
func newTestHandlers(t *testing.T) (*handlers, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
// Stub catalogd so we can run end-to-end happy paths without the
|
||||||
|
// real catalogd up. The stub returns a 200-shaped registerResponse.
|
||||||
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/catalog/register":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"manifest":{"name":"x","dataset_id":"d","schema_fingerprint":"sha256:x","objects":[]},"existing":false}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(stub.Close)
|
||||||
|
|
||||||
|
storaged := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(storaged.Close)
|
||||||
|
|
||||||
|
h := &handlers{
|
||||||
|
storagedURL: strings.TrimRight(storaged.URL, "/"),
|
||||||
|
catalogd: catalogclient.New(stub.URL),
|
||||||
|
hc: &http.Client{},
|
||||||
|
maxBytes: 256 << 20,
|
||||||
|
}
|
||||||
|
return h, stub
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountedRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutesMounted(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
r := mountedRouter(h)
|
||||||
|
found := false
|
||||||
|
chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||||
|
if method == "POST" && route == "/ingest" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if !found {
|
||||||
|
t.Error("POST /ingest not mounted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleIngest_MissingNameQueryParam(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
r := mountedRouter(h)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/ingest",
|
||||||
|
"multipart/form-data; boundary=x", strings.NewReader(""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on missing name param, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(body), "name") {
|
||||||
|
t.Errorf("error body should mention 'name', got %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleIngest_MalformedMultipart(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
r := mountedRouter(h)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/ingest?name=test",
|
||||||
|
"multipart/form-data; boundary=xyz", strings.NewReader("garbage not multipart"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on malformed multipart, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleIngest_MissingFormFile(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
r := mountedRouter(h)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Valid multipart with no "file" field.
|
||||||
|
body := bytes.NewReader([]byte(
|
||||||
|
"--xyz\r\n" +
|
||||||
|
"Content-Disposition: form-data; name=\"other\"\r\n" +
|
||||||
|
"\r\n" +
|
||||||
|
"value\r\n" +
|
||||||
|
"--xyz--\r\n",
|
||||||
|
))
|
||||||
|
resp, err := http.Post(srv.URL+"/ingest?name=test",
|
||||||
|
"multipart/form-data; boundary=xyz", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on missing form file, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleIngest_BodyTooLarge(t *testing.T) {
|
||||||
|
h, _ := newTestHandlers(t)
|
||||||
|
h.maxBytes = 1024 // tiny cap so we hit it without huge upload
|
||||||
|
r := mountedRouter(h)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
big := bytes.Repeat([]byte("x"), 4096)
|
||||||
|
resp, err := http.Post(srv.URL+"/ingest?name=test",
|
||||||
|
"multipart/form-data; boundary=xyz", bytes.NewReader(big))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||||
|
t.Errorf("expected 4xx on oversize body, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapeKeyPath(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"a/b/c.parquet", "a/b/c.parquet"},
|
||||||
|
{"data sets/x.parquet", "data%20sets/x.parquet"},
|
||||||
|
{"O'Reilly/key", "O%27Reilly/key"},
|
||||||
|
{"datasets/proof/abc.parquet", "datasets/proof/abc.parquet"},
|
||||||
|
{"", ""},
|
||||||
|
{"/", "/"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.in, func(t *testing.T) {
|
||||||
|
got := escapeKeyPath(tc.in)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("escapeKeyPath(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParquetKeyPath_Format(t *testing.T) {
|
||||||
|
// Lock the content-addressed key shape per scrum C-DRIFT.
|
||||||
|
// Failure here means a dataset's parquet would land at an
|
||||||
|
// unexpected key, breaking schema-drift idempotency.
|
||||||
|
if !strings.Contains(parquetKeyPath, "%s") {
|
||||||
|
t.Errorf("parquetKeyPath should be a fmt template, got %q", parquetKeyPath)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(parquetKeyPath, "datasets/") {
|
||||||
|
t.Errorf("parquetKeyPath should be under datasets/, got %q", parquetKeyPath)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(parquetKeyPath, ".parquet") {
|
||||||
|
t.Errorf("parquetKeyPath should end with .parquet, got %q", parquetKeyPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// materializer — Go-side build_evidence_index runner. Reads source
|
||||||
|
// JSONL streams in `data/_kb/`, transforms each row to an
|
||||||
|
// EvidenceRecord, writes day-partitioned output under `data/evidence/`
|
||||||
|
// + an audit-grade receipt under `reports/distillation/<ts>/`.
|
||||||
|
//
|
||||||
|
// Mirrors the Bun runner at scripts/distillation/build_evidence_index.ts
|
||||||
|
// — both runtimes can run against the same root and produce
|
||||||
|
// interoperable outputs (per ADR-001 #4: same logic, on-wire
|
||||||
|
// JSON shape preserved).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// materializer # full run, write outputs
|
||||||
|
// materializer -dry-run # count, no writes
|
||||||
|
// materializer -root /home/profit/lakehouse # custom repo root
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/materializer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := flag.String("root", defaultRoot(), "lakehouse repo root (defaults to $LH_DISTILL_ROOT or current dir)")
|
||||||
|
dryRun := flag.Bool("dry-run", false, "count rows but do not write outputs")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
recordedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
|
||||||
|
res, err := materializer.MaterializeAll(materializer.MaterializeOptions{
|
||||||
|
Root: *root,
|
||||||
|
Transforms: materializer.Transforms,
|
||||||
|
RecordedAt: recordedAt,
|
||||||
|
DryRun: *dryRun,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("materializer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
suffix := ""
|
||||||
|
if *dryRun {
|
||||||
|
suffix = " (DRY RUN)"
|
||||||
|
}
|
||||||
|
fmt.Printf("[evidence_index] %d read · %d written · %d skipped · %d deduped%s\n",
|
||||||
|
res.Totals.RowsRead, res.Totals.RowsWritten, res.Totals.RowsSkipped, res.Totals.RowsDeduped, suffix)
|
||||||
|
for _, s := range res.Sources {
|
||||||
|
if !s.RowsPresent {
|
||||||
|
fmt.Printf(" %s: (missing — skipped)\n", s.SourceFileRelPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s: read=%d wrote=%d skip=%d dedup=%d\n",
|
||||||
|
s.SourceFileRelPath, s.RowsRead, s.RowsWritten, s.RowsSkipped, s.RowsDeduped)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*dryRun {
|
||||||
|
fmt.Printf("[evidence_index] receipt: %s\n", res.ReceiptPath)
|
||||||
|
fmt.Printf("[evidence_index] validation_pass=%v\n", res.Receipt.ValidationPass)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !res.Receipt.ValidationPass {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRoot() string {
|
||||||
|
if r := os.Getenv("LH_DISTILL_ROOT"); r != "" {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if cwd, err := os.Getwd(); err == nil {
|
||||||
|
return cwd
|
||||||
|
}
|
||||||
|
return "."
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
// matrixd is the matrix indexer service. Wraps internal/matrix's
|
||||||
|
// Retriever with HTTP routes per docs/SPEC.md §3.4.
|
||||||
|
//
|
||||||
|
// Routes:
|
||||||
|
// POST /matrix/search — multi-corpus retrieve+merge,
|
||||||
|
// with optional playbook boost
|
||||||
|
// GET /matrix/corpora — list known vectord indexes
|
||||||
|
// POST /matrix/relevance — adjacency-pollution filter
|
||||||
|
// POST /matrix/downgrade — strong-model downgrade gate
|
||||||
|
// POST /matrix/playbooks/record — record a single (query → answer)
|
||||||
|
// success for the learning loop
|
||||||
|
// POST /matrix/playbooks/bulk — bulk-record N successes; useful
|
||||||
|
// for backfilling historical
|
||||||
|
// placement data into the
|
||||||
|
// playbook substrate
|
||||||
|
//
|
||||||
|
// matrixd talks to embedd (for query-text embedding) and vectord
|
||||||
|
// (for per-corpus search) via HTTP. Both URLs come from
|
||||||
|
// [matrixd] config; gateway sets them to its own upstream URLs so
|
||||||
|
// matrixd inherits the same provider topology.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/matrix"
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxRequestBytes = 4 << 20 // 4 MiB cap on request bodies
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "lakehouse.toml", "path to TOML config")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := shared.LoadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if cfg.Matrixd.EmbeddURL == "" || cfg.Matrixd.VectordURL == "" {
|
||||||
|
slog.Error("matrixd: embedd_url and vectord_url required in [matrixd]")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
retriever := matrix.New(cfg.Matrixd.EmbeddURL, cfg.Matrixd.VectordURL)
|
||||||
|
h := &handlers{r: retriever, weakModels: cfg.Models.WeakModels}
|
||||||
|
|
||||||
|
if err := shared.Run("matrixd", cfg.Matrixd.Bind, h.register, cfg.Auth); err != nil {
|
||||||
|
slog.Error("server", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type handlers struct {
|
||||||
|
r *matrix.Retriever
|
||||||
|
// weakModels comes from cfg.Models.WeakModels at startup. Threaded
|
||||||
|
// into every DowngradeInput so the gate uses the configured list
|
||||||
|
// instead of matrix.DefaultWeakModels. nil/empty falls back to
|
||||||
|
// the package default — matches pre-Phase 2 behavior.
|
||||||
|
weakModels []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) register(r chi.Router) {
|
||||||
|
r.Post("/matrix/search", h.handleSearch)
|
||||||
|
r.Get("/matrix/corpora", h.handleCorpora)
|
||||||
|
r.Post("/matrix/relevance", h.handleRelevance)
|
||||||
|
r.Post("/matrix/downgrade", h.handleDowngrade)
|
||||||
|
r.Post("/matrix/playbooks/record", h.handlePlaybookRecord)
|
||||||
|
r.Post("/matrix/playbooks/bulk", h.handlePlaybookBulk)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req matrix.SearchRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.r.Search(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeMatrixError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// relevanceRequest is the POST /matrix/relevance body. Threshold
|
||||||
|
// defaults to matrix.DefaultRelevanceThreshold when zero.
|
||||||
|
type relevanceRequest struct {
|
||||||
|
Focus matrix.FocusFile `json:"focus"`
|
||||||
|
Chunks []matrix.CandidateChunk `json:"chunks"`
|
||||||
|
Threshold float64 `json:"threshold,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleRelevance(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req relevanceRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Chunks) == 0 {
|
||||||
|
http.Error(w, "chunks must be non-empty", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
threshold := req.Threshold
|
||||||
|
if threshold == 0 {
|
||||||
|
threshold = matrix.DefaultRelevanceThreshold
|
||||||
|
}
|
||||||
|
res := matrix.FilterChunks(req.Focus, req.Chunks, threshold)
|
||||||
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// playbookRecordRequest is the POST /matrix/playbooks/record body.
|
||||||
|
// Corpus is optional; defaults to matrix.DefaultPlaybookCorpus.
|
||||||
|
// Role is optional; when set, gates Shape A boost + Shape B inject
|
||||||
|
// at retrieve time so this entry only fires for queries with the
|
||||||
|
// same role (real_001 cross-role bleed fix). Empty Role = current
|
||||||
|
// behavior (no gate).
|
||||||
|
type playbookRecordRequest struct {
|
||||||
|
QueryText string `json:"query_text"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
AnswerID string `json:"answer_id"`
|
||||||
|
AnswerCorpus string `json:"answer_corpus"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Corpus string `json:"corpus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handlePlaybookRecord(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req playbookRecordRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := matrix.NewPlaybookEntryWithRole(req.QueryText, req.Role, req.AnswerID, req.AnswerCorpus, req.Score, req.Tags)
|
||||||
|
if err := entry.Validate(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pbID, err := h.r.Record(r.Context(), entry, req.Corpus)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("playbook record", "err", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"playbook_id": pbID,
|
||||||
|
"query_text": entry.QueryText,
|
||||||
|
"answer_id": entry.AnswerID,
|
||||||
|
"answer_corpus": entry.AnswerCorpus,
|
||||||
|
"score": entry.Score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// playbookBulkRequest is the POST /matrix/playbooks/bulk body —
|
||||||
|
// component C (operational rating wiring). Used to backfill
|
||||||
|
// historical placement data, or batch-record a session's worth of
|
||||||
|
// coordinator click-tracking. Each Entry is recorded independently;
|
||||||
|
// failures are reported per-entry without aborting the batch.
|
||||||
|
type playbookBulkRequest struct {
|
||||||
|
Entries []playbookRecordRequest `json:"entries"`
|
||||||
|
Corpus string `json:"corpus,omitempty"` // applies to all if entry-level not set
|
||||||
|
}
|
||||||
|
|
||||||
|
// playbookBulkResult reports per-entry outcomes plus the aggregate
|
||||||
|
// count. Errors include the entry index so callers can locate the
|
||||||
|
// offending record without diffing.
|
||||||
|
type playbookBulkResult struct {
|
||||||
|
Recorded int `json:"recorded"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Results []playbookBulkItemResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type playbookBulkItemResult struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
PlaybookID string `json:"playbook_id,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handlePlaybookBulk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req playbookBulkRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Entries) == 0 {
|
||||||
|
http.Error(w, "entries must be non-empty", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := playbookBulkResult{
|
||||||
|
Results: make([]playbookBulkItemResult, len(req.Entries)),
|
||||||
|
}
|
||||||
|
for i, item := range req.Entries {
|
||||||
|
corpus := item.Corpus
|
||||||
|
if corpus == "" {
|
||||||
|
corpus = req.Corpus
|
||||||
|
}
|
||||||
|
entry := matrix.NewPlaybookEntryWithRole(item.QueryText, item.Role, item.AnswerID, item.AnswerCorpus, item.Score, item.Tags)
|
||||||
|
if err := entry.Validate(); err != nil {
|
||||||
|
out.Results[i] = playbookBulkItemResult{Index: i, Error: err.Error()}
|
||||||
|
out.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pbID, err := h.r.Record(r.Context(), entry, corpus)
|
||||||
|
if err != nil {
|
||||||
|
out.Results[i] = playbookBulkItemResult{Index: i, Error: err.Error()}
|
||||||
|
out.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Results[i] = playbookBulkItemResult{Index: i, PlaybookID: pbID}
|
||||||
|
out.Recorded++
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downgradeRequest is the POST /matrix/downgrade body. Mirrors
|
||||||
|
// matrix.DowngradeInput. When ForceFullOverride is omitted from
|
||||||
|
// the body, the value falls back to matrixd's process env
|
||||||
|
// (LH_FORCE_FULL_ENRICHMENT) — an opinionated default that lets
|
||||||
|
// operators set the env var on the matrixd unit and have every
|
||||||
|
// gate decision honor it without per-request changes. Per
|
||||||
|
// 2026-04-29 cross-lineage scrum (Opus WARN): callers that want
|
||||||
|
// deterministic gate behavior independent of matrixd's env should
|
||||||
|
// pass ForceFullOverride explicitly in the body.
|
||||||
|
type downgradeRequest struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
ForcedMode bool `json:"forced_mode,omitempty"`
|
||||||
|
ForceFullOverride *bool `json:"force_full_override,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleDowngrade(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req downgradeRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Mode == "" || req.Model == "" {
|
||||||
|
http.Error(w, "mode and model are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in := matrix.NewDowngradeInputFromEnv(req.Mode, req.Model, req.ForcedMode)
|
||||||
|
if req.ForceFullOverride != nil {
|
||||||
|
// Explicit body override beats env, useful for tooling that
|
||||||
|
// wants to ask "what would the gate do under these conditions"
|
||||||
|
// without env pollution.
|
||||||
|
in.ForceFullOverride = *req.ForceFullOverride
|
||||||
|
}
|
||||||
|
in.WeakModels = h.weakModels
|
||||||
|
writeJSON(w, http.StatusOK, matrix.MaybeDowngrade(in))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleCorpora(w http.ResponseWriter, r *http.Request) {
|
||||||
|
names, err := h.r.Corpora(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("matrix corpora", "err", err)
|
||||||
|
http.Error(w, "vectord unavailable", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"corpora": names, "count": len(names)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||||
|
defer r.Body.Close()
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||||
|
var maxErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "http: request body too large") {
|
||||||
|
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
http.Error(w, "decode body: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
slog.Warn("matrix write json", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMatrixError maps internal/matrix sentinels to HTTP statuses.
|
||||||
|
// Corpus / embed failures bubble up as 502 (the upstream service is
|
||||||
|
// what's wrong); validation errors are 400.
|
||||||
|
func writeMatrixError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, matrix.ErrEmptyCorpora),
|
||||||
|
errors.Is(err, matrix.ErrEmptyQuery):
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
case errors.Is(err, matrix.ErrCorpus),
|
||||||
|
errors.Is(err, matrix.ErrEmbed):
|
||||||
|
slog.Warn("matrix upstream", "err", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
default:
|
||||||
|
slog.Error("matrix", "err", err)
|
||||||
|
http.Error(w, "internal", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/matrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestRouter builds the matrixd router with a Retriever pointing at
|
||||||
|
// unreachable URLs. Contract-drift assertions in this file fire BEFORE
|
||||||
|
// any retriever call, so the unreachable-upstream behavior only matters
|
||||||
|
// for tests that exercise the success path (none here).
|
||||||
|
func newTestRouter(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
h := &handlers{r: matrix.New("http://127.0.0.1:0", "http://127.0.0.1:0")}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaybookRecord_OldFieldNameRejected locks against a regression of
|
||||||
|
// the 2026-04-30 driver/matrixd contract drift: the playbook_lift driver
|
||||||
|
// briefly sent `{"query": ...}` while matrixd parsed `{"query_text": ...}`.
|
||||||
|
// Empty QueryText fails Validate() with "query_text required", which is
|
||||||
|
// the exact 400 the harness saw. If anyone renames the JSON tag, this
|
||||||
|
// test catches it before the harness has to.
|
||||||
|
func TestPlaybookRecord_OldFieldNameRejected(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body := []byte(`{"query":"x","answer_id":"y","answer_corpus":"z","score":1.0}`)
|
||||||
|
req := httptest.NewRequest("POST", "/matrix/playbooks/record", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for old field name, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "query_text required") {
|
||||||
|
t.Errorf("expected validation error to mention query_text, got %q", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaybookRecord_CurrentFieldName proves the right field name parses
|
||||||
|
// and reaches the retriever. We can't assert 200 without a live retriever,
|
||||||
|
// but we CAN assert the response is NOT a 400 from the validate step —
|
||||||
|
// which is the drift-detector counterpart to the test above.
|
||||||
|
func TestPlaybookRecord_CurrentFieldName(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"query_text": "forklift operator OSHA-30",
|
||||||
|
"answer_id": "worker_42",
|
||||||
|
"answer_corpus": "workers",
|
||||||
|
"score": 1.0,
|
||||||
|
"tags": []string{"reality-test"},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/matrix/playbooks/record", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
// Retriever will fail (unreachable upstream); expected outcomes are
|
||||||
|
// 502 (bad gateway, mapped from upstream HTTP error) or 500 (network
|
||||||
|
// error). Anything that's NOT a 400 means we cleared validation.
|
||||||
|
if w.Code == http.StatusBadRequest {
|
||||||
|
t.Errorf("valid request rejected at validation step: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaybookRecord_ScoreOutOfRange locks the score-bounds invariant
|
||||||
|
// from internal/matrix/playbook.go. Negative or >1.0 scores must 400.
|
||||||
|
func TestPlaybookRecord_ScoreOutOfRange(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
for _, s := range []float64{-0.1, 1.1, 99} {
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"query_text": "x",
|
||||||
|
"answer_id": "y",
|
||||||
|
"answer_corpus": "z",
|
||||||
|
"score": s,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/matrix/playbooks/record", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("score=%v should be rejected, got %d", s, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRelevance_EmptyChunks locks the explicit empty-chunks 400 in
|
||||||
|
// handleRelevance. Keeps callers from silently getting an empty result
|
||||||
|
// when their request was malformed.
|
||||||
|
func TestRelevance_EmptyChunks(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body := []byte(`{"focus":{},"chunks":[]}`)
|
||||||
|
req := httptest.NewRequest("POST", "/matrix/relevance", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on empty chunks, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRoutesMounted asserts that every route in handlers.register(r)
|
||||||
|
// resolves to a handler — i.e. none of them would 404 against a request.
|
||||||
|
// Closes R-005 for matrixd (router-level wiring test).
|
||||||
|
func TestRoutesMounted(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
cases := []struct {
|
||||||
|
method, path string
|
||||||
|
}{
|
||||||
|
{"POST", "/matrix/search"},
|
||||||
|
{"GET", "/matrix/corpora"},
|
||||||
|
{"POST", "/matrix/relevance"},
|
||||||
|
{"POST", "/matrix/downgrade"},
|
||||||
|
{"POST", "/matrix/playbooks/record"},
|
||||||
|
{"POST", "/matrix/playbooks/bulk"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader([]byte(`{}`)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code == http.StatusNotFound {
|
||||||
|
t.Errorf("%s %s returned 404 — route not mounted", tc.method, tc.path)
|
||||||
|
}
|
||||||
|
if w.Code == http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("%s %s returned 405 — wrong method registered", tc.method, tc.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
// mcpd is the Model Context Protocol server that exposes Lakehouse
|
||||||
|
// capabilities as MCP tools. Replaces the Bun mcp-server.ts surface
|
||||||
|
// (the MCP-tool-only subset; HTTP demo routes stay Bun until G5).
|
||||||
|
//
|
||||||
|
// Tools exposed:
|
||||||
|
// list_datasets GET /v1/catalog/list
|
||||||
|
// get_manifest GET /v1/catalog/manifest/<name>
|
||||||
|
// query_sql POST /v1/sql
|
||||||
|
// embed_text POST /v1/embed
|
||||||
|
// search_vectors POST /v1/vectors/index/<name>/search
|
||||||
|
//
|
||||||
|
// Transport: StdioTransport (the universal MCP transport — Claude
|
||||||
|
// Desktop, Claude Code, MCP CLI all speak this). Other transports
|
||||||
|
// (SSE, HTTP) can be added later by changing the Run call.
|
||||||
|
//
|
||||||
|
// Setup for Claude Desktop / Claude Code:
|
||||||
|
// bin/mcpd --gateway http://127.0.0.1:3110
|
||||||
|
// (configure your client to spawn this binary as an MCP server)
|
||||||
|
//
|
||||||
|
// Why not in cmd/gateway: separation of concerns. Gateway is HTTP
|
||||||
|
// for direct-API callers; mcpd is stdio for MCP consumers. Keeping
|
||||||
|
// them separate means each can be deployed / restarted / monitored
|
||||||
|
// without affecting the other.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
gatewayURL := flag.String("gateway", "http://127.0.0.1:3110",
|
||||||
|
"Gateway URL (where mcpd proxies all tool calls)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
srv := buildServer(*gatewayURL)
|
||||||
|
|
||||||
|
if err := srv.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
|
||||||
|
log.Fatalf("mcpd: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServer assembles the MCP server with all tools wired against
|
||||||
|
// the given gateway URL. Extracted from main() so tests can build
|
||||||
|
// the server with a test gateway URL.
|
||||||
|
func buildServer(gatewayURL string) *mcp.Server {
|
||||||
|
srv := mcp.NewServer(&mcp.Implementation{
|
||||||
|
Name: "lakehouse",
|
||||||
|
Version: "v0.1.0",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
gw := newGatewayClient(gatewayURL)
|
||||||
|
|
||||||
|
mcp.AddTool(srv, &mcp.Tool{
|
||||||
|
Name: "list_datasets",
|
||||||
|
Description: "List all datasets registered in the catalog. " +
|
||||||
|
"Returns dataset_id, name, schema_fingerprint, row_count " +
|
||||||
|
"per dataset.",
|
||||||
|
}, gw.listDatasets)
|
||||||
|
|
||||||
|
mcp.AddTool(srv, &mcp.Tool{
|
||||||
|
Name: "get_manifest",
|
||||||
|
Description: "Fetch the manifest for a single dataset by name. " +
|
||||||
|
"Includes schema fingerprint, parquet object keys, row count, " +
|
||||||
|
"created_at_unix_ns.",
|
||||||
|
}, gw.getManifest)
|
||||||
|
|
||||||
|
mcp.AddTool(srv, &mcp.Tool{
|
||||||
|
Name: "query_sql",
|
||||||
|
Description: "Execute a SQL query against the registered datasets. " +
|
||||||
|
"Returns columns + rows. SQL is interpreted by DuckDB; standard " +
|
||||||
|
"SQL plus DuckDB-specific functions (read_parquet, etc.) work.",
|
||||||
|
}, gw.querySQL)
|
||||||
|
|
||||||
|
mcp.AddTool(srv, &mcp.Tool{
|
||||||
|
Name: "embed_text",
|
||||||
|
Description: "Embed one or more texts via the configured embedding " +
|
||||||
|
"model (default: nomic-embed-text). Returns one vector per text " +
|
||||||
|
"in the same order as the input.",
|
||||||
|
}, gw.embedText)
|
||||||
|
|
||||||
|
mcp.AddTool(srv, &mcp.Tool{
|
||||||
|
Name: "search_vectors",
|
||||||
|
Description: "Find the top-K nearest neighbors of a query vector " +
|
||||||
|
"in the named HNSW index. K defaults to 10 if omitted.",
|
||||||
|
}, gw.searchVectors)
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatewayClient holds the HTTP client + base URL for proxying tool
|
||||||
|
// calls to the Go gateway. Per-tool latency is on the order of a
|
||||||
|
// gateway round-trip; the 30s timeout accommodates the slowest
|
||||||
|
// expected SQL query without holding stdio sessions indefinitely.
|
||||||
|
type gatewayClient struct {
|
||||||
|
baseURL string
|
||||||
|
hc *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGatewayClient(baseURL string) *gatewayClient {
|
||||||
|
return &gatewayClient{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
hc: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tool argument structs (jsonschema tags drive schema generation) ──
|
||||||
|
|
||||||
|
type listDatasetsArgs struct{}
|
||||||
|
|
||||||
|
type getManifestArgs struct {
|
||||||
|
Name string `json:"name" jsonschema:"the dataset name to fetch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type querySQLArgs struct {
|
||||||
|
SQL string `json:"sql" jsonschema:"the SQL query to execute"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedTextArgs struct {
|
||||||
|
Texts []string `json:"texts" jsonschema:"the texts to embed"`
|
||||||
|
Model string `json:"model,omitempty" jsonschema:"optional model name (defaults to embedd's configured default)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchVectorsArgs struct {
|
||||||
|
IndexName string `json:"index_name" jsonschema:"the index to search"`
|
||||||
|
Vector []float32 `json:"vector" jsonschema:"the query vector"`
|
||||||
|
K int `json:"k,omitempty" jsonschema:"top-K to return (default 10)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tool handlers ──
|
||||||
|
|
||||||
|
func (g *gatewayClient) listDatasets(ctx context.Context, _ *mcp.CallToolRequest, _ listDatasetsArgs) (*mcp.CallToolResult, any, error) {
|
||||||
|
body, err := g.proxy(ctx, http.MethodGet, "/v1/catalog/list", nil)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err), nil, nil
|
||||||
|
}
|
||||||
|
return jsonResult(body), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gatewayClient) getManifest(ctx context.Context, _ *mcp.CallToolRequest, args getManifestArgs) (*mcp.CallToolResult, any, error) {
|
||||||
|
if args.Name == "" {
|
||||||
|
return errorResult(fmt.Errorf("name is required")), nil, nil
|
||||||
|
}
|
||||||
|
path := "/v1/catalog/manifest/" + url.PathEscape(args.Name)
|
||||||
|
body, err := g.proxy(ctx, http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err), nil, nil
|
||||||
|
}
|
||||||
|
return jsonResult(body), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gatewayClient) querySQL(ctx context.Context, _ *mcp.CallToolRequest, args querySQLArgs) (*mcp.CallToolResult, any, error) {
|
||||||
|
if strings.TrimSpace(args.SQL) == "" {
|
||||||
|
return errorResult(fmt.Errorf("sql is required")), nil, nil
|
||||||
|
}
|
||||||
|
reqBody, _ := json.Marshal(map[string]string{"sql": args.SQL})
|
||||||
|
body, err := g.proxy(ctx, http.MethodPost, "/v1/sql", reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err), nil, nil
|
||||||
|
}
|
||||||
|
return jsonResult(body), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gatewayClient) embedText(ctx context.Context, _ *mcp.CallToolRequest, args embedTextArgs) (*mcp.CallToolResult, any, error) {
|
||||||
|
if len(args.Texts) == 0 {
|
||||||
|
return errorResult(fmt.Errorf("texts is required")), nil, nil
|
||||||
|
}
|
||||||
|
payload := map[string]any{"texts": args.Texts}
|
||||||
|
if args.Model != "" {
|
||||||
|
payload["model"] = args.Model
|
||||||
|
}
|
||||||
|
reqBody, _ := json.Marshal(payload)
|
||||||
|
body, err := g.proxy(ctx, http.MethodPost, "/v1/embed", reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err), nil, nil
|
||||||
|
}
|
||||||
|
return jsonResult(body), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gatewayClient) searchVectors(ctx context.Context, _ *mcp.CallToolRequest, args searchVectorsArgs) (*mcp.CallToolResult, any, error) {
|
||||||
|
if args.IndexName == "" {
|
||||||
|
return errorResult(fmt.Errorf("index_name is required")), nil, nil
|
||||||
|
}
|
||||||
|
if len(args.Vector) == 0 {
|
||||||
|
return errorResult(fmt.Errorf("vector is required")), nil, nil
|
||||||
|
}
|
||||||
|
payload := map[string]any{"vector": args.Vector}
|
||||||
|
if args.K > 0 {
|
||||||
|
payload["k"] = args.K
|
||||||
|
}
|
||||||
|
reqBody, _ := json.Marshal(payload)
|
||||||
|
path := "/v1/vectors/index/" + url.PathEscape(args.IndexName) + "/search"
|
||||||
|
body, err := g.proxy(ctx, http.MethodPost, path, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err), nil, nil
|
||||||
|
}
|
||||||
|
return jsonResult(body), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxy makes a request to the gateway and returns the response body
|
||||||
|
// on success. Non-2xx status codes return an error with the body
|
||||||
|
// preview in the message — surfaced to the MCP client as a tool error
|
||||||
|
// rather than a transport-level failure.
|
||||||
|
func (g *gatewayClient) proxy(ctx context.Context, method, path string, body []byte) ([]byte, error) {
|
||||||
|
var rdr io.Reader
|
||||||
|
if body != nil {
|
||||||
|
rdr = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, g.baseURL+path, rdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := g.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("call gateway: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) // 16 MiB tool-response cap
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
preview := respBody
|
||||||
|
if len(preview) > 512 {
|
||||||
|
preview = preview[:512]
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("gateway %s %s: status %d: %s",
|
||||||
|
method, path, resp.StatusCode, string(preview))
|
||||||
|
}
|
||||||
|
return respBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorResult wraps an error as an MCP tool error result. The MCP
|
||||||
|
// protocol distinguishes "tool ran but reported failure" (returned
|
||||||
|
// in CallToolResult.IsError + content) from "tool threw" (returned
|
||||||
|
// as the third return value). We use the former so the LLM caller
|
||||||
|
// sees the error text and can decide how to react, rather than
|
||||||
|
// surfacing the error as transport noise.
|
||||||
|
func errorResult(err error) *mcp.CallToolResult {
|
||||||
|
return &mcp.CallToolResult{
|
||||||
|
IsError: true,
|
||||||
|
Content: []mcp.Content{
|
||||||
|
&mcp.TextContent{Text: err.Error()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonResult wraps a JSON byte slice as a successful tool result.
|
||||||
|
// The content is text — MCP clients render it; LLMs parse it as
|
||||||
|
// JSON when their tool config indicates so.
|
||||||
|
func jsonResult(body []byte) *mcp.CallToolResult {
|
||||||
|
return &mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{
|
||||||
|
&mcp.TextContent{Text: string(body)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tests the MCP tool surface end-to-end without a subprocess: spin
|
||||||
|
// up a fake gateway via httptest, build the MCP server pointed at
|
||||||
|
// it, connect a client via in-memory transports, call each tool.
|
||||||
|
|
||||||
|
// fakeGateway returns an httptest.Server that responds to the routes
|
||||||
|
// mcpd proxies. Each route's handler is configurable via the routes map.
|
||||||
|
func fakeGateway(t *testing.T, routes map[string]http.HandlerFunc) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
for path, h := range routes {
|
||||||
|
mux.HandleFunc(path, h)
|
||||||
|
}
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Errorf("fakeGateway: unexpected route %s %s", r.Method, r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect builds the mcpd server pointed at gatewayURL, connects an
|
||||||
|
// in-memory client, and returns a ready-to-use ClientSession.
|
||||||
|
func connect(t *testing.T, gatewayURL string) *mcp.ClientSession {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
srv := buildServer(gatewayURL)
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
|
||||||
|
|
||||||
|
t1, t2 := mcp.NewInMemoryTransports()
|
||||||
|
if _, err := srv.Connect(ctx, t1, nil); err != nil {
|
||||||
|
t.Fatalf("server connect: %v", err)
|
||||||
|
}
|
||||||
|
session, err := client.Connect(ctx, t2, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client connect: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = session.Close() })
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
func callTool(t *testing.T, session *mcp.ClientSession, name string, args any) *mcp.CallToolResult {
|
||||||
|
t.Helper()
|
||||||
|
res, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||||
|
Name: name,
|
||||||
|
Arguments: args,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CallTool %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultText(t *testing.T, res *mcp.CallToolResult) string {
|
||||||
|
t.Helper()
|
||||||
|
if len(res.Content) == 0 {
|
||||||
|
t.Fatal("result has no content")
|
||||||
|
}
|
||||||
|
tc, ok := res.Content[0].(*mcp.TextContent)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("first content is %T, want *mcp.TextContent", res.Content[0])
|
||||||
|
}
|
||||||
|
return tc.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTools(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, nil)
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res, err := session.ListTools(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTools: %v", err)
|
||||||
|
}
|
||||||
|
want := map[string]bool{
|
||||||
|
"list_datasets": false,
|
||||||
|
"get_manifest": false,
|
||||||
|
"query_sql": false,
|
||||||
|
"embed_text": false,
|
||||||
|
"search_vectors": false,
|
||||||
|
}
|
||||||
|
for _, tool := range res.Tools {
|
||||||
|
if _, ok := want[tool.Name]; ok {
|
||||||
|
want[tool.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, found := range want {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected tool %q exposed by mcpd, not in ListTools", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDatasets_HappyPath(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
||||||
|
"/v1/catalog/list": func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"manifests":[{"name":"workers","row_count":500000}],"count":1}`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "list_datasets", listDatasetsArgs{})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("expected success, got IsError with: %s", resultText(t, res))
|
||||||
|
}
|
||||||
|
body := resultText(t, res)
|
||||||
|
if !strings.Contains(body, "workers") || !strings.Contains(body, "500000") {
|
||||||
|
t.Errorf("response missing expected fields: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetManifest_HappyPath(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
||||||
|
"/v1/catalog/manifest/workers": func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"name":"workers","row_count":500000,"schema_fingerprint":"sha256:abc"}`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "get_manifest", getManifestArgs{Name: "workers"})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("expected success, got: %s", resultText(t, res))
|
||||||
|
}
|
||||||
|
body := resultText(t, res)
|
||||||
|
if !strings.Contains(body, "workers") {
|
||||||
|
t.Errorf("missing manifest fields in response: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetManifest_EmptyName_IsError(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, nil) // no handlers — tool should error before hitting gateway
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "get_manifest", getManifestArgs{Name: ""})
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected IsError on empty name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuerySQL_HappyPath(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
||||||
|
"/v1/sql": func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
t.Errorf("/v1/sql got %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"columns":[{"name":"n","type":"BIGINT"}],"rows":[[5]],"row_count":1}`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "query_sql", querySQLArgs{SQL: "SELECT count(*) FROM workers"})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("expected success, got: %s", resultText(t, res))
|
||||||
|
}
|
||||||
|
if !strings.Contains(resultText(t, res), `"row_count":1`) {
|
||||||
|
t.Errorf("response missing row_count field: %s", resultText(t, res))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuerySQL_EmptySQL_IsError(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, nil)
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "query_sql", querySQLArgs{SQL: " "})
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected IsError on whitespace SQL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbedText_HappyPath(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
||||||
|
"/v1/embed": func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"model":"nomic-embed-text","dimension":768,"vectors":[[0.1,0.2]]}`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "embed_text", embedTextArgs{Texts: []string{"hello"}})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("expected success, got: %s", resultText(t, res))
|
||||||
|
}
|
||||||
|
if !strings.Contains(resultText(t, res), `"dimension":768`) {
|
||||||
|
t.Errorf("missing dimension in response: %s", resultText(t, res))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbedText_EmptyTexts_IsError(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, nil)
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "embed_text", embedTextArgs{Texts: nil})
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected IsError on empty texts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchVectors_HappyPath(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
||||||
|
"/v1/vectors/index/test_idx/search": func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"results":[{"id":"v1","distance":0.001}]}`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "search_vectors", searchVectorsArgs{
|
||||||
|
IndexName: "test_idx",
|
||||||
|
Vector: []float32{1, 0, 0, 0},
|
||||||
|
K: 5,
|
||||||
|
})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("expected success, got: %s", resultText(t, res))
|
||||||
|
}
|
||||||
|
if !strings.Contains(resultText(t, res), `"id":"v1"`) {
|
||||||
|
t.Errorf("missing top-1 in response: %s", resultText(t, res))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchVectors_MissingIndex_IsError(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, nil)
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "search_vectors", searchVectorsArgs{
|
||||||
|
Vector: []float32{1, 0, 0, 0},
|
||||||
|
})
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected IsError on missing index_name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchVectors_MissingVector_IsError(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, nil)
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "search_vectors", searchVectorsArgs{
|
||||||
|
IndexName: "test_idx",
|
||||||
|
})
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected IsError on missing vector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayError_PropagatesAsIsError(t *testing.T) {
|
||||||
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
||||||
|
"/v1/sql": func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte("syntax error: no such table"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
session := connect(t, gw.URL)
|
||||||
|
|
||||||
|
res := callTool(t, session, "query_sql", querySQLArgs{SQL: "SELECT * FROM nope"})
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected IsError on gateway 4xx")
|
||||||
|
}
|
||||||
|
body := resultText(t, res)
|
||||||
|
if !strings.Contains(body, "400") {
|
||||||
|
t.Errorf("error result should mention status 400, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayUnreachable_PropagatesAsIsError(t *testing.T) {
|
||||||
|
// Point at a non-listening port — connect will fail.
|
||||||
|
session := connect(t, "http://127.0.0.1:1") // reserved port
|
||||||
|
|
||||||
|
res := callTool(t, session, "list_datasets", listDatasetsArgs{})
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected IsError on unreachable gateway")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
// observerd is the autonomous-iteration witness service. Port of
|
||||||
|
// the load-bearing pieces of mcp-server/observer.ts (Rust system).
|
||||||
|
//
|
||||||
|
// Routes (all under /observer):
|
||||||
|
// GET /observer/health — service liveness + ring size
|
||||||
|
// GET /observer/stats — aggregate counters + recent scenarios
|
||||||
|
// POST /observer/event — record one observed op
|
||||||
|
//
|
||||||
|
// Deferred to follow-up commits (see internal/observer doc):
|
||||||
|
// - POST /observer/review (cloud-LLM hand review fall-back)
|
||||||
|
// - background loops (analyzeErrors, consolidatePlaybooks,
|
||||||
|
// tailOverseerCorrections)
|
||||||
|
// - failure-cluster escalation to LLM Team
|
||||||
|
//
|
||||||
|
// /relevance was already ported to internal/matrix in 9588bd8 and is
|
||||||
|
// not duplicated here.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/observer"
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxRequestBytes = 4 << 20 // 4 MiB cap on request bodies
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "lakehouse.toml", "path to TOML config")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := shared.LoadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistence is optional — empty path = ephemeral (matches the
|
||||||
|
// pathwayd pattern). Production sets a stable path under
|
||||||
|
// /var/lib/lakehouse/observer/ops.jsonl.
|
||||||
|
var persistor *observer.Persistor
|
||||||
|
if cfg.Observerd.PersistPath != "" {
|
||||||
|
persistor, err = observer.NewPersistor(cfg.Observerd.PersistPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("observer persistor", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store := observer.NewStore(persistor)
|
||||||
|
if persistor != nil {
|
||||||
|
n, err := store.Load()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("observer load", "err", err, "loaded", n)
|
||||||
|
} else {
|
||||||
|
slog.Info("observer loaded", "ops", n, "path", cfg.Observerd.PersistPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runner := workflow.NewRunner()
|
||||||
|
// matrixd URL: prefer explicit observerd config field, fall back
|
||||||
|
// to gateway's matrixd_url so a single-toml deploy works without
|
||||||
|
// duplicating the address.
|
||||||
|
matrixdURL := cfg.Gateway.MatrixdURL
|
||||||
|
registerBuiltinModes(runner, matrixdURL, cfg.Models.WeakModels)
|
||||||
|
|
||||||
|
h := &handlers{store: store, runner: runner}
|
||||||
|
if err := shared.Run("observerd", cfg.Observerd.Bind, h.register, cfg.Auth); err != nil {
|
||||||
|
slog.Error("server", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type handlers struct {
|
||||||
|
store *observer.Store
|
||||||
|
runner *workflow.Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) register(r chi.Router) {
|
||||||
|
r.Get("/observer/stats", h.handleStats)
|
||||||
|
r.Post("/observer/event", h.handleEvent)
|
||||||
|
r.Post("/observer/workflow/run", h.handleWorkflowRun)
|
||||||
|
r.Get("/observer/workflow/modes", h.handleWorkflowModes)
|
||||||
|
r.Post("/observer/inbox", h.handleInbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inboxMessage is the POST /observer/inbox body — an incoming
|
||||||
|
// real-world signal (email or SMS) that a coordinator would receive
|
||||||
|
// and act on. The handler only RECORDS it as an ObservedOp; whether
|
||||||
|
// to trigger a downstream matrix.search or workflow is the caller's
|
||||||
|
// concern. Keeps observer's witness role pure.
|
||||||
|
type inboxMessage struct {
|
||||||
|
Type string `json:"type"` // "email" | "sms"
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Priority string `json:"priority"` // "urgent" | "high" | "medium" | "low"
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validInboxPriorities is the closed set of priority values. Per
|
||||||
|
// scrum (Kimi WARN): a free-form priority string was accepted, so
|
||||||
|
// downstream consumers parsing on exact enum could see "nonsense"
|
||||||
|
// values. Now rejected at the boundary.
|
||||||
|
var validInboxPriorities = map[string]bool{
|
||||||
|
"": true, // empty defaults to medium below
|
||||||
|
"urgent": true,
|
||||||
|
"high": true,
|
||||||
|
"medium": true,
|
||||||
|
"low": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// inboxMaxBodyChars caps the inbox body at a sane size so a large
|
||||||
|
// payload doesn't bloat observer's ring buffer or JSONL log. Per
|
||||||
|
// scrum (Opus WARN): only emptiness was checked, so a multi-MB body
|
||||||
|
// would be accepted unconditionally.
|
||||||
|
const inboxMaxBodyChars = 8 * 1024
|
||||||
|
|
||||||
|
// inboxMaxFieldChars caps subject/sender/tag at a much tighter size.
|
||||||
|
// These appear inside the InputSummary string + JSONL log entry, so
|
||||||
|
// long values bloat every record and embedded newlines corrupt
|
||||||
|
// downstream line-based parsers.
|
||||||
|
const inboxMaxFieldChars = 256
|
||||||
|
|
||||||
|
func sanitizeInboxField(s string) string {
|
||||||
|
// Strip newlines so multi-line attacker-controlled strings don't
|
||||||
|
// corrupt the JSONL log (one-line-per-op invariant).
|
||||||
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
|
s = strings.ReplaceAll(s, "\r", " ")
|
||||||
|
if len(s) > inboxMaxFieldChars {
|
||||||
|
s = s[:inboxMaxFieldChars]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleInbox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var msg inboxMessage
|
||||||
|
if !decodeJSON(w, r, &msg) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg.Type != "email" && msg.Type != "sms" {
|
||||||
|
http.Error(w, "type must be 'email' or 'sms'", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(msg.Body) == "" {
|
||||||
|
http.Error(w, "body required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(msg.Body) > inboxMaxBodyChars {
|
||||||
|
http.Error(w, fmt.Sprintf("body exceeds %d chars", inboxMaxBodyChars), http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validInboxPriorities[msg.Priority] {
|
||||||
|
http.Error(w, "priority must be urgent|high|medium|low", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg.Priority == "" {
|
||||||
|
msg.Priority = "medium"
|
||||||
|
}
|
||||||
|
// Sanitize the fields that go into the InputSummary string so
|
||||||
|
// newlines don't corrupt the JSONL log.
|
||||||
|
sender := sanitizeInboxField(msg.Sender)
|
||||||
|
subject := sanitizeInboxField(msg.Subject)
|
||||||
|
tag := sanitizeInboxField(msg.Tag)
|
||||||
|
op := observer.ObservedOp{
|
||||||
|
Endpoint: "/observer/inbox/" + msg.Type,
|
||||||
|
InputSummary: fmt.Sprintf("from=%s priority=%s tag=%s subject=%s", sender, msg.Priority, tag, subject),
|
||||||
|
OutputSummary: msg.Body,
|
||||||
|
Source: observer.SourceInbox,
|
||||||
|
Success: true,
|
||||||
|
}
|
||||||
|
if err := h.store.Record(op); err != nil {
|
||||||
|
if errors.Is(err, observer.ErrInvalidOp) {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("observer record inbox", "err", err)
|
||||||
|
http.Error(w, "internal", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats := h.store.Stats()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"accepted": true,
|
||||||
|
"type": msg.Type,
|
||||||
|
"priority": msg.Priority,
|
||||||
|
"ring_size": stats.Total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleStats(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.store.Stats())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var op observer.ObservedOp
|
||||||
|
if !decodeJSON(w, r, &op) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.Record(op); err != nil {
|
||||||
|
if errors.Is(err, observer.ErrInvalidOp) {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("observer record", "err", err)
|
||||||
|
http.Error(w, "internal", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats := h.store.Stats()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"accepted": true,
|
||||||
|
"ring_size": stats.Total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// workflowRunRequest is the POST /observer/workflow/run body — a
|
||||||
|
// Workflow definition in JSON form (matches Archon's YAML shape but
|
||||||
|
// JSON-serialized for the HTTP path).
|
||||||
|
type workflowRunRequest struct {
|
||||||
|
Workflow workflow.Workflow `json:"workflow"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleWorkflowRun(r http.ResponseWriter, req *http.Request) {
|
||||||
|
var body workflowRunRequest
|
||||||
|
if !decodeJSON(r, req, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := h.runner.Run(req.Context(), body.Workflow)
|
||||||
|
// Record per-node provenance into the observer ring AS the
|
||||||
|
// workflow runs — same shape as any other ObservedOp so the
|
||||||
|
// existing /observer/stats aggregation surfaces workflow ops
|
||||||
|
// alongside scenario ops without a schema change.
|
||||||
|
for _, n := range res.Nodes {
|
||||||
|
op := observer.ObservedOp{
|
||||||
|
Endpoint: "/observer/workflow/run/" + body.Workflow.Name + "/" + n.NodeID,
|
||||||
|
InputSummary: fmt.Sprintf("workflow=%s node=%s mode=%s", body.Workflow.Name, n.NodeID, n.Mode),
|
||||||
|
Success: n.Error == "",
|
||||||
|
DurationMs: n.DurationMs,
|
||||||
|
OutputSummary: summarizeOutput(n.Output),
|
||||||
|
Source: observer.SourceWorkflow,
|
||||||
|
Error: n.Error,
|
||||||
|
Timestamp: n.StartedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
if recErr := h.store.Record(op); recErr != nil {
|
||||||
|
slog.Warn("workflow run: provenance record failed", "err", recErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Aborting errors (cycle, missing dep, unknown mode) — surface
|
||||||
|
// as 4xx because the workflow definition itself is wrong.
|
||||||
|
slog.Warn("workflow run aborted", "err", err)
|
||||||
|
writeJSON(r, http.StatusBadRequest, map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"result": res,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(r, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleWorkflowModes(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
modes := h.runner.Modes()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"modes": modes,
|
||||||
|
"count": len(modes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeOutput renders a workflow node's output map for the
|
||||||
|
// ObservedOp's OutputSummary string. Best-effort — long values get
|
||||||
|
// truncated rather than ballooning the ring buffer's memory.
|
||||||
|
func summarizeOutput(output map[string]any) string {
|
||||||
|
if output == nil {
|
||||||
|
return "(nil)"
|
||||||
|
}
|
||||||
|
bs, err := json.Marshal(output)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("(marshal err: %v)", err)
|
||||||
|
}
|
||||||
|
if len(bs) > 256 {
|
||||||
|
return string(bs[:256]) + "...(truncated)"
|
||||||
|
}
|
||||||
|
return string(bs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerBuiltinModes wires the modes the runner knows about. The
|
||||||
|
// pure-function wrappers (matrix.relevance, matrix.downgrade,
|
||||||
|
// distillation.score, drift.scorer) are direct Go calls. matrix.search
|
||||||
|
// is HTTP-backed, pointed at the configured matrixd_url so workflows
|
||||||
|
// can compose retrieval into multi-pass measurement chains.
|
||||||
|
//
|
||||||
|
// Fixture modes (fixture.echo, fixture.upper) stay registered for
|
||||||
|
// the workflow_smoke that proves the runner mechanics independently
|
||||||
|
// of the real modes' availability.
|
||||||
|
//
|
||||||
|
// Real-mode follow-ups still pending:
|
||||||
|
// - playbook.record (HTTP to matrixd)
|
||||||
|
// - playbook.lookup (HTTP to matrixd)
|
||||||
|
// - llm.chat (HTTP to gateway /v1/chat)
|
||||||
|
func registerBuiltinModes(r *workflow.Runner, matrixdURL string, weakModels []string) {
|
||||||
|
// Fixture modes for runner mechanics smokes.
|
||||||
|
r.RegisterMode("fixture.echo", func(_ workflow.Context, input map[string]any) (map[string]any, error) {
|
||||||
|
out := make(map[string]any, len(input))
|
||||||
|
for k, v := range input {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
})
|
||||||
|
r.RegisterMode("fixture.upper", func(_ workflow.Context, input map[string]any) (map[string]any, error) {
|
||||||
|
prompt, _ := input["prompt"].(string)
|
||||||
|
return map[string]any{"upper": strings.ToUpper(prompt)}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Real modes — pure-function wrappers (no I/O).
|
||||||
|
r.RegisterMode("matrix.relevance", workflow.MatrixRelevance)
|
||||||
|
// matrix.downgrade reads weakModels from config — Phase 2.
|
||||||
|
// nil/empty falls back to matrix.DefaultWeakModels per the
|
||||||
|
// MatrixDowngradeWithWeakList factory contract.
|
||||||
|
r.RegisterMode("matrix.downgrade", workflow.MatrixDowngradeWithWeakList(weakModels))
|
||||||
|
r.RegisterMode("distillation.score", workflow.DistillationScore)
|
||||||
|
r.RegisterMode("drift.scorer", workflow.DriftScorer)
|
||||||
|
|
||||||
|
// HTTP-backed modes — only register when their backend URL is set.
|
||||||
|
// matrixd_url defaults to a known address but tests/dev may run
|
||||||
|
// without matrixd.
|
||||||
|
if matrixdURL != "" {
|
||||||
|
hc := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
r.RegisterMode("matrix.search", workflow.MatrixSearch(matrixdURL, hc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||||
|
defer r.Body.Close()
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||||
|
var maxErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "http: request body too large") {
|
||||||
|
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
http.Error(w, "decode body: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
slog.Warn("observer write json", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/observer"
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestRouter builds the observerd router with an in-memory store
|
||||||
|
// and a workflow runner with no modes registered. Closes R-005 for
|
||||||
|
// observerd.
|
||||||
|
//
|
||||||
|
// Returns chi.Router (not http.Handler) so chi.Walk works without a
|
||||||
|
// type assertion that would panic if a future refactor wraps the
|
||||||
|
// router in plain net/http middleware.
|
||||||
|
func newTestRouter(t *testing.T) chi.Router {
|
||||||
|
t.Helper()
|
||||||
|
h := &handlers{
|
||||||
|
store: observer.NewStore(nil),
|
||||||
|
runner: workflow.NewRunner(),
|
||||||
|
}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutesMounted(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
want := map[string]bool{
|
||||||
|
"GET /observer/stats": false,
|
||||||
|
"POST /observer/event": false,
|
||||||
|
"POST /observer/workflow/run": false,
|
||||||
|
"GET /observer/workflow/modes": false,
|
||||||
|
"POST /observer/inbox": false,
|
||||||
|
}
|
||||||
|
_ = chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||||
|
key := method + " " + route
|
||||||
|
if _, ok := want[key]; ok {
|
||||||
|
want[key] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
for k, mounted := range want {
|
||||||
|
if !mounted {
|
||||||
|
t.Errorf("route not mounted: %s", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_GET(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
req := httptest.NewRequest("GET", "/observer/stats", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowModes_GET(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
req := httptest.NewRequest("GET", "/observer/workflow/modes", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvent_InvalidOp locks the validation path: an ObservedOp with
|
||||||
|
// missing required fields must 400, not 500. Without this assertion,
|
||||||
|
// observer.ErrInvalidOp could silently slip into the 500 branch on a
|
||||||
|
// future refactor and clients would see "internal" instead of the
|
||||||
|
// actual validation error.
|
||||||
|
func TestEvent_InvalidOp(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
// Empty body — no endpoint, no source — fails ObservedOp validation.
|
||||||
|
body := []byte(`{}`)
|
||||||
|
req := httptest.NewRequest("POST", "/observer/event", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on invalid op, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkflowRun_AllProvenanceRecordedPostRun proves the gap ratified
|
||||||
|
// in ADR-005 Decision 5.3: handleWorkflowRun calls runner.Run
|
||||||
|
// synchronously and only records ObservedOps from the returned
|
||||||
|
// RunResult AFTER Run completes. A crash mid-Run would lose ALL
|
||||||
|
// provenance for that workflow.
|
||||||
|
//
|
||||||
|
// The test pauses inside a node, samples observer state (must be 0),
|
||||||
|
// unblocks, then samples again (must be N). If a future commit adds
|
||||||
|
// per-node streaming (e.g. runner.NodeHook firing before Run returns),
|
||||||
|
// the first assertion fires — that's the intentional test-as-spec
|
||||||
|
// lock so the behavior change is visible in `go test` instead of
|
||||||
|
// surfacing under load.
|
||||||
|
func TestWorkflowRun_AllProvenanceRecordedPostRun(t *testing.T) {
|
||||||
|
pauseCh := make(chan struct{})
|
||||||
|
|
||||||
|
runner := workflow.NewRunner()
|
||||||
|
runner.RegisterMode("test.pause", func(_ workflow.Context, _ map[string]any) (map[string]any, error) {
|
||||||
|
<-pauseCh
|
||||||
|
return map[string]any{"unpaused": true}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
h := &handlers{
|
||||||
|
store: observer.NewStore(nil),
|
||||||
|
runner: runner,
|
||||||
|
}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
|
||||||
|
// Two-node serial workflow so we have something to record post-run.
|
||||||
|
body := []byte(`{"workflow":{"name":"adr_005_5_3","nodes":[
|
||||||
|
{"id":"n1","mode":"test.pause"},
|
||||||
|
{"id":"n2","mode":"test.pause","depends_on":["n1"]}
|
||||||
|
]}}`)
|
||||||
|
|
||||||
|
// Send the request in a goroutine — it'll block until pauseCh closes.
|
||||||
|
done := make(chan int)
|
||||||
|
go func() {
|
||||||
|
req := httptest.NewRequest("POST", "/observer/workflow/run", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
done <- w.Code
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait briefly for the runner to enter n1 and block on pauseCh.
|
||||||
|
// 50ms is conservative; the goroutine + chi routing + topo sort
|
||||||
|
// take well under that on this hardware.
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// LOCK: store MUST be empty while runner.Run is paused.
|
||||||
|
// If a future change adds streaming-record-as-each-node-finishes,
|
||||||
|
// n1's record would land here as soon as n1 returns — but n1
|
||||||
|
// hasn't returned yet (we're paused before it does), so the
|
||||||
|
// only way this assertion passes is if recording is post-run-only.
|
||||||
|
if got := h.store.Stats().Total; got != 0 {
|
||||||
|
t.Errorf("expected 0 observer ops during paused run, got %d "+
|
||||||
|
"(if non-zero, ADR-005 Decision 5.3 must be updated — recording "+
|
||||||
|
"is no longer post-run-only)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblock all paused nodes (channel close broadcasts to all receivers).
|
||||||
|
close(pauseCh)
|
||||||
|
|
||||||
|
// Wait for the handler to return + record post-run.
|
||||||
|
if code := <-done; code != http.StatusOK {
|
||||||
|
t.Errorf("workflow run failed: HTTP %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOCK: store MUST have 2 ops after run completes.
|
||||||
|
if got := h.store.Stats().Total; got != 2 {
|
||||||
|
t.Errorf("expected 2 observer ops after run, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInbox_AcceptsValidEmail locks the happy-path contract for the
|
||||||
|
// /observer/inbox route — accepts an email message with required
|
||||||
|
// fields, records as ObservedOp, returns 200 with ring-size.
|
||||||
|
func TestInbox_AcceptsValidEmail(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body := []byte(`{"type":"email","sender":"client@northstar.com","subject":"URGENT: 50 forklift ops","body":"Need 50 forklift operators in Cleveland OH for next week. Day shift.","priority":"urgent","tag":"alpha-surge"}`)
|
||||||
|
req := httptest.NewRequest("POST", "/observer/inbox", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), `"accepted":true`) {
|
||||||
|
t.Errorf("expected accepted=true, got %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInbox_RejectsBadType locks the validation: type must be
|
||||||
|
// "email" or "sms", anything else is 400.
|
||||||
|
func TestInbox_RejectsBadType(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body := []byte(`{"type":"smoke-signal","sender":"x","body":"y","priority":"high"}`)
|
||||||
|
req := httptest.NewRequest("POST", "/observer/inbox", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on bad type, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInbox_RejectsEmptyBody locks the body-required invariant.
|
||||||
|
func TestInbox_RejectsEmptyBody(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body := []byte(`{"type":"email","sender":"x","body":"","priority":"high"}`)
|
||||||
|
req := httptest.NewRequest("POST", "/observer/inbox", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on empty body, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInbox_RejectsBadPriority locks the priority enum (per
|
||||||
|
// scrum Kimi WARN — free-form priority strings were accepted).
|
||||||
|
func TestInbox_RejectsBadPriority(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body := []byte(`{"type":"email","sender":"x","body":"y","priority":"nonsense"}`)
|
||||||
|
req := httptest.NewRequest("POST", "/observer/inbox", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on bad priority, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInbox_RejectsOversizedBody locks the body-size cap (per scrum
|
||||||
|
// Opus WARN — only emptiness was checked before).
|
||||||
|
func TestInbox_RejectsOversizedBody(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
bigBody := strings.Repeat("x", inboxMaxBodyChars+1)
|
||||||
|
body := []byte(`{"type":"email","sender":"x","body":"` + bigBody + `","priority":"high"}`)
|
||||||
|
req := httptest.NewRequest("POST", "/observer/inbox", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("expected 413 on oversized body, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkflowRun_UnknownMode locks the 400 path on workflow definitions
|
||||||
|
// that reference modes not registered with the runner. The harness's
|
||||||
|
// reality test runs depend on this so an unknown-mode misconfiguration
|
||||||
|
// surfaces as a definition error, not a server error.
|
||||||
|
func TestWorkflowRun_UnknownMode(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body := []byte(`{"workflow":{"name":"t","nodes":[{"id":"n1","mode":"does.not.exist"}]}}`)
|
||||||
|
req := httptest.NewRequest("POST", "/observer/workflow/run", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on unknown mode, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
// pathwayd is the pathway memory service. Wraps internal/pathway's
|
||||||
|
// Store with HTTP routes for the Mem0-style operations defined in
|
||||||
|
// ADR-004.
|
||||||
|
//
|
||||||
|
// Routes (all under /pathway):
|
||||||
|
// POST /pathway/add — new trace with fresh UID
|
||||||
|
// POST /pathway/add_idempotent — UID-keyed add or replay-bump
|
||||||
|
// POST /pathway/update — replace content for an existing UID
|
||||||
|
// POST /pathway/revise — new revision linked to predecessor
|
||||||
|
// POST /pathway/retire — mark trace retired (excluded from search)
|
||||||
|
// GET /pathway/get/{uid} — fetch one trace (incl. retired)
|
||||||
|
// GET /pathway/history/{uid} — backward chain via predecessor links
|
||||||
|
// POST /pathway/search — filter-based listing
|
||||||
|
// GET /pathway/stats — total/active/retired counters
|
||||||
|
//
|
||||||
|
// Persistence: optional. Empty [pathwayd].persist_path = in-memory
|
||||||
|
// only (matches vectord G1's pattern). Set a path for durable
|
||||||
|
// per-trace JSONL append.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/pathway"
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxRequestBytes = 4 << 20 // 4 MiB cap on request bodies
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "lakehouse.toml", "path to TOML config")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := shared.LoadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistence is optional — empty path = in-memory ephemeral.
|
||||||
|
var persistor *pathway.Persistor
|
||||||
|
if cfg.Pathwayd.PersistPath != "" {
|
||||||
|
persistor, err = pathway.NewPersistor(cfg.Pathwayd.PersistPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("pathway persistor", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store := pathway.NewStore(persistor)
|
||||||
|
if persistor != nil {
|
||||||
|
n, err := store.Load()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("pathway load", "err", err, "loaded", n)
|
||||||
|
} else {
|
||||||
|
slog.Info("pathway loaded", "events", n, "path", cfg.Pathwayd.PersistPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &handlers{store: store}
|
||||||
|
|
||||||
|
if err := shared.Run("pathwayd", cfg.Pathwayd.Bind, h.register, cfg.Auth); err != nil {
|
||||||
|
slog.Error("server", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type handlers struct {
|
||||||
|
store *pathway.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) register(r chi.Router) {
|
||||||
|
r.Post("/pathway/add", h.handleAdd)
|
||||||
|
r.Post("/pathway/add_idempotent", h.handleAddIdempotent)
|
||||||
|
r.Post("/pathway/update", h.handleUpdate)
|
||||||
|
r.Post("/pathway/revise", h.handleRevise)
|
||||||
|
r.Post("/pathway/retire", h.handleRetire)
|
||||||
|
r.Get("/pathway/get/{uid}", h.handleGet)
|
||||||
|
r.Get("/pathway/history/{uid}", h.handleHistory)
|
||||||
|
r.Post("/pathway/search", h.handleSearch)
|
||||||
|
r.Get("/pathway/stats", h.handleStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── request shapes ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
type addRequest struct {
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type addIdempotentRequest struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateRequest struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reviseRequest struct {
|
||||||
|
PredecessorUID string `json:"predecessor_uid"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type retireRequest struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchRequest struct {
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
ContentContains string `json:"content_contains,omitempty"`
|
||||||
|
CreatedAfterNs int64 `json:"created_after_ns,omitempty"`
|
||||||
|
CreatedBeforeNs int64 `json:"created_before_ns,omitempty"`
|
||||||
|
IncludeRetired bool `json:"include_retired,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handlers) handleAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req addRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr, err := h.store.Add(req.Content, req.Tags...)
|
||||||
|
if writeStoreError(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, tr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleAddIdempotent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req addIdempotentRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr, err := h.store.AddIdempotent(req.UID, req.Content, req.Tags...)
|
||||||
|
if writeStoreError(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req updateRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.Update(req.UID, req.Content); writeStoreError(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleRevise(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req reviseRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr, err := h.store.Revise(req.PredecessorUID, req.Content, req.Tags...)
|
||||||
|
if writeStoreError(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, tr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleRetire(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req retireRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.Retire(req.UID); writeStoreError(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := chi.URLParam(r, "uid")
|
||||||
|
tr, err := h.store.Get(uid)
|
||||||
|
if writeStoreError(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := chi.URLParam(r, "uid")
|
||||||
|
chain, err := h.store.History(uid)
|
||||||
|
if writeStoreError(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"chain": chain,
|
||||||
|
"length": len(chain),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req searchRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results := h.store.Search(pathway.SearchFilter{
|
||||||
|
Tag: req.Tag,
|
||||||
|
ContentContains: req.ContentContains,
|
||||||
|
CreatedAfterNs: req.CreatedAfterNs,
|
||||||
|
CreatedBeforeNs: req.CreatedBeforeNs,
|
||||||
|
IncludeRetired: req.IncludeRetired,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"results": results,
|
||||||
|
"count": len(results),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleStats(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.store.Stats())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||||
|
defer r.Body.Close()
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||||
|
var maxErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "http: request body too large") {
|
||||||
|
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
http.Error(w, "decode body: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
slog.Warn("pathway write json", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeStoreError maps internal/pathway sentinel errors to HTTP
|
||||||
|
// status codes. Returns true if a response was written (caller
|
||||||
|
// should return). Returns false on success (caller continues).
|
||||||
|
func writeStoreError(w http.ResponseWriter, err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, pathway.ErrNotFound):
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
case errors.Is(err, pathway.ErrPredecessorMissing):
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
case errors.Is(err, pathway.ErrEmptyUID),
|
||||||
|
errors.Is(err, pathway.ErrInvalidContent):
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
case errors.Is(err, pathway.ErrCycle):
|
||||||
|
http.Error(w, err.Error(), http.StatusConflict)
|
||||||
|
default:
|
||||||
|
slog.Error("pathway store", "err", err)
|
||||||
|
http.Error(w, "internal", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/pathway"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestRouter builds the pathwayd router with an in-memory store
|
||||||
|
// (nil persistor). Closes R-005 for pathwayd: 9 routes mounted with
|
||||||
|
// no router-level test prior to this file.
|
||||||
|
//
|
||||||
|
// Returns chi.Router (not http.Handler) so chi.Walk works without a
|
||||||
|
// type assertion that would panic if a future refactor wraps the
|
||||||
|
// router in plain net/http middleware.
|
||||||
|
func newTestRouter(t *testing.T) chi.Router {
|
||||||
|
t.Helper()
|
||||||
|
h := &handlers{store: pathway.NewStore(nil)}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutesMounted(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
want := map[string]string{
|
||||||
|
"POST /pathway/add": "",
|
||||||
|
"POST /pathway/add_idempotent": "",
|
||||||
|
"POST /pathway/update": "",
|
||||||
|
"POST /pathway/revise": "",
|
||||||
|
"POST /pathway/retire": "",
|
||||||
|
"GET /pathway/get/{uid}": "",
|
||||||
|
"GET /pathway/history/{uid}": "",
|
||||||
|
"POST /pathway/search": "",
|
||||||
|
"GET /pathway/stats": "",
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
_ = chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||||
|
got[method+" "+route] = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
for k := range want {
|
||||||
|
if !got[k] {
|
||||||
|
t.Errorf("route not mounted: %s", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdd_RoundTrip locks the happy-path contract: POST a content blob,
|
||||||
|
// receive a 201 with a trace, GET it back at /pathway/get/{uid}.
|
||||||
|
// Catches drift in either the add response shape or the get path.
|
||||||
|
func TestAdd_RoundTrip(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body := []byte(`{"content":{"hello":"world"},"tags":["test"]}`)
|
||||||
|
req := httptest.NewRequest("POST", "/pathway/add", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201 on add, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_GET(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
req := httptest.NewRequest("GET", "/pathway/stats", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 on stats, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddIdempotent_MissingUID locks the validation: empty UID must
|
||||||
|
// 4xx rather than silently accepting (which would defeat the
|
||||||
|
// idempotency contract).
|
||||||
|
func TestAddIdempotent_MissingUID(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body := []byte(`{"content":{"x":1}}`)
|
||||||
|
req := httptest.NewRequest("POST", "/pathway/add_idempotent", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code/100 != 4 {
|
||||||
|
t.Errorf("missing uid should 4xx, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetire_NonexistentUID locks the not-found path. The store rejects
|
||||||
|
// retiring traces that don't exist; the handler must surface that as a
|
||||||
|
// 4xx, not a 5xx.
|
||||||
|
func TestRetire_NonexistentUID(t *testing.T) {
|
||||||
|
r := newTestRouter(t)
|
||||||
|
body := []byte(`{"uid":"does-not-exist"}`)
|
||||||
|
req := httptest.NewRequest("POST", "/pathway/retire", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code/100 != 4 {
|
||||||
|
t.Errorf("retire of nonexistent uid should 4xx, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -99,7 +99,7 @@ func main() {
|
|||||||
|
|
||||||
h := &handlers{db: db}
|
h := &handlers{db: db}
|
||||||
|
|
||||||
if err := shared.Run("queryd", cfg.Queryd.Bind, h.register); err != nil {
|
if err := shared.Run("queryd", cfg.Queryd.Bind, h.register, cfg.Auth); err != nil {
|
||||||
slog.Error("server", "err", err)
|
slog.Error("server", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Closes R-005 for queryd: cmd-level tests for the /sql handler's
|
||||||
|
// pre-DB paths (decode, body cap, empty SQL). The actual SQL execution
|
||||||
|
// path needs DuckDB so it lives in the smoke chain + proof harness.
|
||||||
|
//
|
||||||
|
// We construct handlers with a nil *sql.DB — the tests only exercise
|
||||||
|
// paths that return early before db.QueryContext. Tests that would
|
||||||
|
// reach the db are covered by GOLAKE-040 in the proof harness.
|
||||||
|
|
||||||
|
func mountedRouter() chi.Router {
|
||||||
|
h := &handlers{db: nil}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutesMounted(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
found := false
|
||||||
|
chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||||
|
if method == "POST" && route == "/sql" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if !found {
|
||||||
|
t.Error("POST /sql not mounted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSQL_BodyTooLarge(t *testing.T) {
|
||||||
|
// 4xx range — see embedd's TestHandleEmbed_BodyTooLarge for the
|
||||||
|
// 413-vs-400 detail. The contract is "client error, fails loud."
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
big := bytes.Repeat([]byte("x"), maxSQLBodyBytes+1024)
|
||||||
|
resp, err := http.Post(srv.URL+"/sql", "application/json", bytes.NewReader(big))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||||
|
t.Errorf("expected 4xx on oversize, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSQL_MalformedJSON_400(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/sql", "application/json", strings.NewReader("not json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on malformed, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSQL_WrongFieldName_400 locks the JSON tag on sqlRequest.SQL
|
||||||
|
// against drift. The 2026-04-30 playbook_lift harness sent {"q": "..."}
|
||||||
|
// — the Go decoder ignores unknown fields by default, so req.SQL stays
|
||||||
|
// empty and the empty-check fires with "sql is empty". If anyone renames
|
||||||
|
// the JSON tag, callers POSTing the new (wrong) shape would hit this
|
||||||
|
// same path; this test makes the contract explicit so the failure mode
|
||||||
|
// is documented rather than discovered during a reality run.
|
||||||
|
func TestHandleSQL_WrongFieldName_400(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cases := []string{
|
||||||
|
`{"q":"SELECT 1"}`, // the actual 2026-04-30 harness shape
|
||||||
|
`{"query":"SELECT 1"}`, // matrixd-style drift in the other direction
|
||||||
|
`{"statement":"SELECT 1"}`,
|
||||||
|
}
|
||||||
|
for _, body := range cases {
|
||||||
|
t.Run(body, func(t *testing.T) {
|
||||||
|
resp, err := http.Post(srv.URL+"/sql", "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on wrong field name, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
rb, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(rb), "sql is empty") {
|
||||||
|
t.Errorf("expected 'sql is empty' to anchor the contract, got %q", string(rb))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSQL_EmptySQL_400(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cases := []string{
|
||||||
|
`{"sql":""}`,
|
||||||
|
`{"sql":" "}`,
|
||||||
|
`{"sql":"\n\t \n"}`,
|
||||||
|
}
|
||||||
|
for _, body := range cases {
|
||||||
|
t.Run(body, func(t *testing.T) {
|
||||||
|
resp, err := http.Post(srv.URL+"/sql", "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on empty/whitespace SQL, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaxSQLBodyBytes_Reasonable(t *testing.T) {
|
||||||
|
// SQL strings shouldn't be huge — 64 KiB is generous for queryd
|
||||||
|
// (DuckDB statements above 64 KiB are pathological). Locking the
|
||||||
|
// constant prevents an accidental refactor from blowing this open.
|
||||||
|
if maxSQLBodyBytes < 16<<10 {
|
||||||
|
t.Errorf("maxSQLBodyBytes=%d below sane SQL minimum (16 KiB)", maxSQLBodyBytes)
|
||||||
|
}
|
||||||
|
if maxSQLBodyBytes > 1<<20 {
|
||||||
|
t.Errorf("maxSQLBodyBytes=%d above sane SQL maximum (1 MiB)", maxSQLBodyBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrimaryBucket_Constant(t *testing.T) {
|
||||||
|
// Locks the logical bucket name — secrets provider lookup keys
|
||||||
|
// against this. Refactor that flips this would silently fail
|
||||||
|
// secret resolution for queryd at startup.
|
||||||
|
if primaryBucket != "primary" {
|
||||||
|
t.Errorf("primaryBucket = %q, want %q", primaryBucket, "primary")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// replay — Go-side distillation replay runner. Closes audit-FULL
|
||||||
|
// phase 7 live invocation on the Go side. Mirrors
|
||||||
|
// scripts/distillation/replay.ts; both runtimes append to the same
|
||||||
|
// `data/_kb/replay_runs.jsonl` shape (schema=replay_run.v1).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// replay -task "rebuild evidence index"
|
||||||
|
// replay -task "..." -allow-escalation
|
||||||
|
// replay -task "..." -no-retrieval # baseline mode
|
||||||
|
// replay -task "..." -dry-run # synthetic, no LLM
|
||||||
|
// replay -task "..." -root /home/profit/lakehouse # custom repo root
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/replay"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
task := flag.String("task", "", "input task to replay")
|
||||||
|
localOnly := flag.Bool("local-only", false, "never escalate; record validation result only")
|
||||||
|
allowEscalation := flag.Bool("allow-escalation", false, "fall back to the bigger model when local validation fails")
|
||||||
|
noRetrieval := flag.Bool("no-retrieval", false, "baseline mode: skip retrieval bundle (still logs)")
|
||||||
|
dryRun := flag.Bool("dry-run", false, "synthesize a deterministic response — no LLM call")
|
||||||
|
root := flag.String("root", replay.DefaultRoot(), "lakehouse repo root (defaults to $LH_DISTILL_ROOT or cwd)")
|
||||||
|
gateway := flag.String("gateway", "", "override gateway URL (default: $LH_GATEWAY_URL or http://localhost:3110)")
|
||||||
|
localModel := flag.String("local-model", "", "override local model name")
|
||||||
|
escalationModel := flag.String("escalation-model", "", "override escalation model name")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *task == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, `usage: replay -task "<input>" [-local-only] [-allow-escalation] [-no-retrieval] [-dry-run]`)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := replay.Replay(context.Background(), replay.ReplayRequest{
|
||||||
|
Task: *task,
|
||||||
|
LocalOnly: *localOnly,
|
||||||
|
AllowEscalation: *allowEscalation,
|
||||||
|
NoRetrieval: *noRetrieval,
|
||||||
|
DryRun: *dryRun,
|
||||||
|
GatewayURL: *gateway,
|
||||||
|
LocalModel: *localModel,
|
||||||
|
EscalationModel: *escalationModel,
|
||||||
|
}, *root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "replay: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[replay] run_id=%s\n", res.RecordedRunID)
|
||||||
|
if res.ContextBundle == nil {
|
||||||
|
fmt.Println("[replay] retrieval: DISABLED")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[replay] retrieval: %d playbooks\n", len(res.ContextBundle.RetrievedPlaybooks))
|
||||||
|
}
|
||||||
|
fmt.Printf("[replay] escalation_path: %s\n", strings.Join(res.EscalationPath, " → "))
|
||||||
|
fmt.Printf("[replay] model_used: %s · %dms\n", res.ModelUsed, res.DurationMs)
|
||||||
|
verdict := "PASS"
|
||||||
|
if !res.ValidationResult.Passed {
|
||||||
|
verdict = "FAIL"
|
||||||
|
}
|
||||||
|
suffix := ""
|
||||||
|
if len(res.ValidationResult.Reasons) > 0 {
|
||||||
|
suffix = " (" + strings.Join(res.ValidationResult.Reasons, "; ") + ")"
|
||||||
|
}
|
||||||
|
fmt.Printf("[replay] validation: %s%s\n", verdict, suffix)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("─── response ───")
|
||||||
|
body := res.ModelResponse
|
||||||
|
if len(body) > 1500 {
|
||||||
|
fmt.Println(body[:1500])
|
||||||
|
fmt.Printf("... [%d more chars]\n", len(body)-1500)
|
||||||
|
} else {
|
||||||
|
fmt.Println(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !res.ValidationResult.Passed {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-16
@@ -24,12 +24,41 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxPutBytes = 256 << 20 // 256 MiB per Qwen Q1 fix
|
// Default per-PUT body cap. Most callers (ingest parquets, catalog
|
||||||
|
// manifests) live well under this. The cap is a safety gate, not a
|
||||||
|
// memory bottleneck — manager.Uploader streams; this just refuses
|
||||||
|
// reads past the limit so a runaway client can't drain the daemon.
|
||||||
|
maxPutBytesDefault = 256 << 20 // 256 MiB
|
||||||
|
|
||||||
|
// Vector-persistence prefix gets a much larger cap because vectord
|
||||||
|
// persists single-file LHV1 indexes that exceed 256 MiB above
|
||||||
|
// ~150K vectors at d=768 (the 500K staffing test's documented gap).
|
||||||
|
// 4 GiB covers >700K vectors at d=768 with HNSW graph overhead and
|
||||||
|
// keeps the simple-Put torn-write guarantee from G1P intact (memory
|
||||||
|
// project_golang_lakehouse.md: "Single Put eliminates the torn-write
|
||||||
|
// class").
|
||||||
|
maxPutBytesVectors = 4 << 30 // 4 GiB
|
||||||
|
|
||||||
|
// Vector-persistence prefix matched against incoming PUT keys. Keep
|
||||||
|
// this in sync with internal/vectord/persistor.go's lhv1KeyFor.
|
||||||
|
vectorsPrefix = "_vectors/"
|
||||||
|
|
||||||
maxConcurrentPut = 4 // 4-slot semaphore on in-flight PUTs
|
maxConcurrentPut = 4 // 4-slot semaphore on in-flight PUTs
|
||||||
retryAfterSecs = "5" // Retry-After header on 503
|
retryAfterSecs = "5" // Retry-After header on 503
|
||||||
primaryBucket = "primary"
|
primaryBucket = "primary"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxPutBytesFor returns the body-cap to apply to a PUT for the given
|
||||||
|
// key. Vectord LHV1 persistence (under "_vectors/") gets the larger
|
||||||
|
// cap; everything else stays at the default. Function-level so a
|
||||||
|
// future operator-driven cap (env, config) can drop in cleanly.
|
||||||
|
func maxPutBytesFor(key string) int64 {
|
||||||
|
if strings.HasPrefix(key, vectorsPrefix) {
|
||||||
|
return maxPutBytesVectors
|
||||||
|
}
|
||||||
|
return maxPutBytesDefault
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "lakehouse.toml", "path to TOML config")
|
configPath := flag.String("config", "lakehouse.toml", "path to TOML config")
|
||||||
secretsPath := flag.String("secrets", "/etc/lakehouse/secrets-go.toml", "path to secrets TOML (Go-side; Rust uses /etc/lakehouse/secrets.toml)")
|
secretsPath := flag.String("secrets", "/etc/lakehouse/secrets-go.toml", "path to secrets TOML (Go-side; Rust uses /etc/lakehouse/secrets.toml)")
|
||||||
@@ -49,7 +78,7 @@ func main() {
|
|||||||
|
|
||||||
h := newHandlers(registry)
|
h := newHandlers(registry)
|
||||||
|
|
||||||
if err := shared.Run("storaged", cfg.Storaged.Bind, h.register); err != nil {
|
if err := shared.Run("storaged", cfg.Storaged.Bind, h.register, cfg.Auth); err != nil {
|
||||||
slog.Error("server", "err", err)
|
slog.Error("server", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -181,15 +210,14 @@ func (h *handlers) handlePut(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Up-front Content-Length cap. Per Opus C3 review: the
|
// Per-key body cap. Vectord LHV1 persistence under "_vectors/"
|
||||||
// manager.Uploader's multipart path runs body reads in goroutines
|
// gets a 4 GiB cap; everything else keeps 256 MiB. Up-front
|
||||||
// and wraps errors in its own types, so *http.MaxBytesError can be
|
// Content-Length check first per Opus C3 review (manager.Uploader's
|
||||||
// buried by the time it reaches us — meaning bodies just over the
|
// multipart path can bury *http.MaxBytesError in its own error
|
||||||
// 5 MiB multipart threshold could surface as 500 instead of 413.
|
// types); MaxBytesReader + string-match fallback below cover
|
||||||
// Catching Content-Length up front returns 413 deterministically
|
// chunked / lying-CL cases.
|
||||||
// when the client honestly declares size; MaxBytesReader + the
|
cap := maxPutBytesFor(key)
|
||||||
// string-match fallback below cover chunked / lying-CL cases.
|
if r.ContentLength > cap {
|
||||||
if r.ContentLength > maxPutBytes {
|
|
||||||
w.Header().Set("Retry-After", retryAfterSecs)
|
w.Header().Set("Retry-After", retryAfterSecs)
|
||||||
http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
|
http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
|
||||||
return
|
return
|
||||||
@@ -208,11 +236,11 @@ func (h *handlers) handlePut(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 256 MiB per-request body cap. Reads beyond this surface as
|
// Per-key body cap as MaxBytesReader so chunked-encoding bodies
|
||||||
// *http.MaxBytesError; for chunked-encoding bodies that's the only
|
// also fail-loud at the limit. Defer LIFO order: r.Body.Close
|
||||||
// signal we get. Defer LIFO order: r.Body.Close fires before
|
// fires before <-h.putSem, so the body is fully drained before
|
||||||
// <-h.putSem, so the body is fully drained before the slot frees.
|
// the slot frees.
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxPutBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, cap)
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
bucket, err := h.reg.Resolve(primaryBucket)
|
bucket, err := h.reg.Resolve(primaryBucket)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/vectord"
|
||||||
|
)
|
||||||
|
|
||||||
func TestValidateKey(t *testing.T) {
|
func TestValidateKey(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -36,3 +40,57 @@ func TestValidateKey(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMaxPutBytesFor(t *testing.T) {
|
||||||
|
// Vectord LHV1 persistence gets the larger cap so single-file
|
||||||
|
// indexes above ~150K vectors at d=768 (the 500K staffing test
|
||||||
|
// gap) can survive a Save without 413. Default cap stays at
|
||||||
|
// 256 MiB for everything else (parquets, manifests, etc.).
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{"vectord LHV1 file", "_vectors/workers_500k.lhv1", maxPutBytesVectors},
|
||||||
|
{"vectord nested", "_vectors/some/nested/index.lhv1", maxPutBytesVectors},
|
||||||
|
{"parquet under datasets/", "datasets/workers/abc.parquet", maxPutBytesDefault},
|
||||||
|
{"catalog manifest", "_catalog/manifests/foo.bin", maxPutBytesDefault},
|
||||||
|
{"plain key", "x.txt", maxPutBytesDefault},
|
||||||
|
{"empty key", "", maxPutBytesDefault},
|
||||||
|
{"key with vectors-ish substring (not prefix)", "datasets/_vectors/x", maxPutBytesDefault},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := maxPutBytesFor(tc.key)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("maxPutBytesFor(%q) = %d, want %d", tc.key, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVectorPrefixSyncWithVectord locks the prefix value to vectord's
|
||||||
|
// VectorPrefix constant. If a future refactor renames either side,
|
||||||
|
// this test surfaces the drift before vectord saves silently bypass
|
||||||
|
// the larger cap.
|
||||||
|
func TestVectorPrefixSyncWithVectord(t *testing.T) {
|
||||||
|
if vectorsPrefix != vectord.VectorPrefix {
|
||||||
|
t.Errorf("storaged vectorsPrefix=%q out of sync with vectord.VectorPrefix=%q",
|
||||||
|
vectorsPrefix, vectord.VectorPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVectorCapAccommodates500KStaffingTest reads the documented gap
|
||||||
|
// (memory project_golang_lakehouse.md): "storaged 256 MiB PUT cap
|
||||||
|
// blocks single-file LHV1 persistence above ~150K vectors at d=768."
|
||||||
|
// 500K vectors at d=768 with HNSW graph overhead is approximately
|
||||||
|
// 4.5 GiB resident, ~700 MiB on disk after compression.
|
||||||
|
// 4 GiB cap covers more than the documented production workload.
|
||||||
|
func TestVectorCapAccommodates500KStaffingTest(t *testing.T) {
|
||||||
|
const fiveHundredKVectorsAtD768Disk = 700 << 20 // ~700 MiB conservative estimate
|
||||||
|
if maxPutBytesVectors < fiveHundredKVectorsAtD768Disk {
|
||||||
|
t.Errorf("maxPutBytesVectors=%d (%.0f MiB) below documented production "+
|
||||||
|
"workload of ~700 MiB for 500K vectors at d=768",
|
||||||
|
maxPutBytesVectors, float64(maxPutBytesVectors)/(1<<20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
// validatord is the staffing-validator service daemon. Hosts:
|
||||||
|
//
|
||||||
|
// POST /validate — dispatch a single artifact to FillValidator,
|
||||||
|
// EmailValidator, or PlaybookValidator
|
||||||
|
// POST /iterate — generate→validate→correct loop (Phase 43 PRD).
|
||||||
|
// Calls chatd for the LLM hop and runs the
|
||||||
|
// validator in-process for the gate.
|
||||||
|
// GET /health — readiness (always 200; roster status reported
|
||||||
|
// in /validate responses)
|
||||||
|
//
|
||||||
|
// Per docs/SPEC.md and architecture_comparison.md "Go primary path":
|
||||||
|
// this closes the last bounded item — the now-Go-side validators get
|
||||||
|
// a network surface so any caller (TS code path, other daemons, agents)
|
||||||
|
// can validate artifacts via gateway /v1/validate or /v1/iterate.
|
||||||
|
//
|
||||||
|
// The roster (worker existence + city/state/role/blacklist) loads
|
||||||
|
// from a JSONL file at startup. Empty path = no roster, worker-existence
|
||||||
|
// checks fail Consistency. Production points this at a roster that's
|
||||||
|
// regenerated from workers_500k.parquet on a schedule.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/langfuse"
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxRequestBytes = 4 << 20 // 4 MiB
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "lakehouse.toml", "path to TOML config")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := shared.LoadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup, err := validator.LoadJSONLRoster(cfg.Validatord.RosterPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("roster load", "path", cfg.Validatord.RosterPath, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
slog.Info("validatord roster",
|
||||||
|
"path", cfg.Validatord.RosterPath,
|
||||||
|
"records", lookup.Len(),
|
||||||
|
)
|
||||||
|
|
||||||
|
chatTimeout := time.Duration(cfg.Validatord.ChatTimeoutSecs) * time.Second
|
||||||
|
if chatTimeout <= 0 {
|
||||||
|
chatTimeout = 240 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &handlers{
|
||||||
|
lookup: lookup,
|
||||||
|
chatdURL: cfg.Validatord.ChatdURL,
|
||||||
|
chatClient: &http.Client{Timeout: chatTimeout},
|
||||||
|
iterCfg: validator.IterateConfig{
|
||||||
|
DefaultMaxIterations: cfg.Validatord.DefaultMaxIterations,
|
||||||
|
DefaultMaxTokens: cfg.Validatord.DefaultMaxTokens,
|
||||||
|
},
|
||||||
|
// Same env loader the middleware uses — getting our own
|
||||||
|
// reference lets us emit child spans of the per-request trace.
|
||||||
|
// nil when Langfuse env isn't set; the iterate handler skips
|
||||||
|
// span emission gracefully in that case.
|
||||||
|
lf: shared.LoadLangfuseFromEnv(),
|
||||||
|
sessionLog: validator.NewSessionLogger(cfg.Validatord.SessionLogPath),
|
||||||
|
}
|
||||||
|
if h.sessionLog != nil {
|
||||||
|
slog.Info("validatord session log", "path", cfg.Validatord.SessionLogPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := shared.Run("validatord", cfg.Validatord.Bind, h.register, cfg.Auth); err != nil {
|
||||||
|
slog.Error("server", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type handlers struct {
|
||||||
|
lookup validator.WorkerLookup
|
||||||
|
chatdURL string
|
||||||
|
chatClient *http.Client
|
||||||
|
iterCfg validator.IterateConfig
|
||||||
|
// lf is the Langfuse client (nil when unconfigured — best-effort
|
||||||
|
// posture matching the rest of the stack). Per-attempt iteration
|
||||||
|
// spans get emitted via lf when set. The HTTP middleware that
|
||||||
|
// creates the parent trace lives in shared.Run; this client lets
|
||||||
|
// validatord's handler emit application-level child spans.
|
||||||
|
lf *langfuse.Client
|
||||||
|
// sessionLog appends one SessionRecord per /v1/iterate session to
|
||||||
|
// `coordinator_sessions.jsonl` for offline DuckDB analysis. nil
|
||||||
|
// when the session log is unconfigured (best-effort posture).
|
||||||
|
sessionLog *validator.SessionLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) register(r chi.Router) {
|
||||||
|
r.Post("/validate", h.handleValidate)
|
||||||
|
r.Post("/iterate", h.handleIterate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRequest is the request body for POST /validate. Mirrors
|
||||||
|
// Rust's ValidateRequest in `crates/gateway/src/v1/validate.rs`.
|
||||||
|
type validateRequest struct {
|
||||||
|
Kind string `json:"kind"` // "fill" | "email" | "playbook"
|
||||||
|
Artifact map[string]any `json:"artifact"`
|
||||||
|
Context map[string]any `json:"context,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleValidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
var req validateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Kind == "" {
|
||||||
|
http.Error(w, "kind is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Artifact == nil {
|
||||||
|
http.Error(w, "artifact is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
report, vErr, kindErr := h.runValidator(req.Kind, req.Artifact, req.Context)
|
||||||
|
switch {
|
||||||
|
case kindErr != nil:
|
||||||
|
http.Error(w, kindErr.Error(), http.StatusBadRequest)
|
||||||
|
case vErr != nil:
|
||||||
|
writeJSON(w, http.StatusUnprocessableEntity, vErr)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusOK, report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runValidator dispatches by kind. Returns (Report, ValidationError, kindErr).
|
||||||
|
// kindErr is non-nil only for unknown kind strings (400).
|
||||||
|
func (h *handlers) runValidator(kind string, artifact, ctx map[string]any) (*validator.Report, *validator.ValidationError, error) {
|
||||||
|
merged := mergeContext(artifact, ctx)
|
||||||
|
a, kindErr := buildArtifact(kind, merged)
|
||||||
|
if kindErr != nil {
|
||||||
|
return nil, nil, kindErr
|
||||||
|
}
|
||||||
|
v, vErr := pickValidator(kind, h.lookup)
|
||||||
|
if vErr != nil {
|
||||||
|
return nil, nil, vErr
|
||||||
|
}
|
||||||
|
report, err := v.Validate(a)
|
||||||
|
if err != nil {
|
||||||
|
var ve *validator.ValidationError
|
||||||
|
if errors.As(err, &ve) {
|
||||||
|
return nil, ve, nil
|
||||||
|
}
|
||||||
|
// Validators only ever return ValidationError; an "any other
|
||||||
|
// error" path means the validator violated its own contract.
|
||||||
|
// Surface as 500 rather than silently coercing.
|
||||||
|
return nil, &validator.ValidationError{
|
||||||
|
Kind: validator.ErrSchema,
|
||||||
|
Reason: "internal validator error: " + err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &report, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildArtifact maps the kind string to the right Artifact union arm.
|
||||||
|
// Unknown kinds return a 400-friendly error.
|
||||||
|
func buildArtifact(kind string, body map[string]any) (validator.Artifact, error) {
|
||||||
|
switch kind {
|
||||||
|
case "fill":
|
||||||
|
return validator.Artifact{FillProposal: body}, nil
|
||||||
|
case "email":
|
||||||
|
return validator.Artifact{EmailDraft: body}, nil
|
||||||
|
case "playbook":
|
||||||
|
return validator.Artifact{Playbook: body}, nil
|
||||||
|
default:
|
||||||
|
return validator.Artifact{}, fmt.Errorf("unknown kind %q — expected fill | email | playbook", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickValidator(kind string, lookup validator.WorkerLookup) (validator.Validator, error) {
|
||||||
|
switch kind {
|
||||||
|
case "fill":
|
||||||
|
return validator.NewFillValidator(lookup), nil
|
||||||
|
case "email":
|
||||||
|
return validator.NewEmailValidator(lookup), nil
|
||||||
|
case "playbook":
|
||||||
|
return validator.PlaybookValidator{}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown kind %q", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeContext folds `context` into `artifact._context` so validators
|
||||||
|
// pull contract metadata uniformly. Caller-supplied artifact._context
|
||||||
|
// wins on key collision (caller knows their own contract).
|
||||||
|
func mergeContext(artifact, ctx map[string]any) map[string]any {
|
||||||
|
if ctx == nil {
|
||||||
|
return artifact
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(artifact)+1)
|
||||||
|
for k, v := range artifact {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
existing, _ := out["_context"].(map[string]any)
|
||||||
|
merged := make(map[string]any, len(ctx)+len(existing))
|
||||||
|
for k, v := range ctx {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range existing {
|
||||||
|
merged[k] = v // existing wins
|
||||||
|
}
|
||||||
|
out["_context"] = merged
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleIterate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
var req validator.IterateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Kind == "" || req.Prompt == "" || req.Provider == "" || req.Model == "" {
|
||||||
|
http.Error(w, "kind, prompt, provider, and model are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the parent trace id, in priority order:
|
||||||
|
// 1. req.TraceID (caller put it in the JSON body)
|
||||||
|
// 2. X-Lakehouse-Trace-Id header (read DIRECTLY here so the path
|
||||||
|
// works even when Langfuse is off and the middleware is a
|
||||||
|
// passthrough — matches Rust's iterate handler shape)
|
||||||
|
// 3. context value set by the langfuse middleware (when Langfuse
|
||||||
|
// is configured)
|
||||||
|
// 4. fallback to a locally-minted time-ordered hex id
|
||||||
|
//
|
||||||
|
// Surfaced 2026-05-02 cross-runtime test: Go was minting fallback
|
||||||
|
// even when caller forwarded the header, because middleware
|
||||||
|
// passthrough mode never read it. Reading the header here closes
|
||||||
|
// the gap and makes Go behavior match Rust regardless of
|
||||||
|
// Langfuse configuration.
|
||||||
|
if req.TraceID == "" {
|
||||||
|
req.TraceID = r.Header.Get(shared.TraceIDHeader)
|
||||||
|
}
|
||||||
|
if req.TraceID == "" {
|
||||||
|
req.TraceID = shared.TraceIDFromCtx(r.Context())
|
||||||
|
}
|
||||||
|
if req.TraceID == "" {
|
||||||
|
req.TraceID = newFallbackTraceID()
|
||||||
|
}
|
||||||
|
|
||||||
|
chat := h.chatCaller()
|
||||||
|
validate := func(kind string, artifact map[string]any) (validator.Report, error) {
|
||||||
|
report, vErr, kindErr := h.runValidator(kind, artifact, req.Context)
|
||||||
|
if kindErr != nil {
|
||||||
|
return validator.Report{}, &validator.ValidationError{
|
||||||
|
Kind: validator.ErrSchema,
|
||||||
|
Reason: kindErr.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vErr != nil {
|
||||||
|
return validator.Report{}, vErr
|
||||||
|
}
|
||||||
|
return *report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire per-attempt span emission when Langfuse is configured —
|
||||||
|
// each chat→validate cycle becomes a child span of the iterate
|
||||||
|
// trace, with prompt/raw/verdict captured for inspection.
|
||||||
|
cfg := h.iterCfg
|
||||||
|
if h.lf != nil {
|
||||||
|
cfg.Tracer = h.iterTracer(r.Context())
|
||||||
|
}
|
||||||
|
|
||||||
|
t0 := time.Now()
|
||||||
|
resp, fail, err := validator.Iterate(r.Context(), req, cfg, chat, validate)
|
||||||
|
durationMs := time.Since(t0).Milliseconds()
|
||||||
|
|
||||||
|
// Emit one session row regardless of success/failure/infra-error.
|
||||||
|
// Best-effort: a failing session log never blocks the response.
|
||||||
|
rosterCheck := h.rosterCheckFor(req.Kind)
|
||||||
|
rec := validator.BuildSessionRecord(req, resp, fail, err, rosterCheck, durationMs)
|
||||||
|
h.sessionLog.Append(rec)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fail != nil {
|
||||||
|
writeJSON(w, http.StatusUnprocessableEntity, fail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterCheckFor returns a closure that verifies an accepted artifact's
|
||||||
|
// candidate IDs all exist in the roster. Used by BuildSessionRecord to
|
||||||
|
// stamp `grounded_in_roster` on each session row — answers the "did
|
||||||
|
// the model produce real workers, not phantoms?" question at offline-
|
||||||
|
// query time.
|
||||||
|
//
|
||||||
|
// Only the `fill` kind has worker IDs to verify. Other kinds return
|
||||||
|
// nil (omits the field from the session record).
|
||||||
|
//
|
||||||
|
// Note: FillValidator already enforces this constraint on every
|
||||||
|
// successful iteration (phantom IDs raise ValidationError::Consistency).
|
||||||
|
// Stamping the bool here surfaces the verified-grounded property as
|
||||||
|
// queryable data, not relying on validator semantics being recalled
|
||||||
|
// during analysis. A future scrum may want to query "show me sessions
|
||||||
|
// where the model guessed real worker IDs without ever needing a
|
||||||
|
// retry" — that's a `iterations=1 AND grounded_in_roster=true` query.
|
||||||
|
func (h *handlers) rosterCheckFor(kind string) func(map[string]any) *bool {
|
||||||
|
if kind != "fill" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(artifact map[string]any) *bool {
|
||||||
|
fills, ok := artifact["fills"].([]any)
|
||||||
|
if !ok {
|
||||||
|
f := false
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
for _, raw := range fills {
|
||||||
|
fill, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
f := false
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
id, _ := fill["candidate_id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
f := false
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
if _, found := h.lookup.Find(id); !found {
|
||||||
|
f := false
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := true
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatCaller wires the iteration loop to chatd via HTTP. Builds the
|
||||||
|
// chat.Request shape, posts to ${chatdURL}/chat, returns the content
|
||||||
|
// string (no choices wrapper — chatd's response is already flat).
|
||||||
|
//
|
||||||
|
// When traceID is non-empty, forwards it as the `X-Lakehouse-Trace-Id`
|
||||||
|
// header so chatd's middleware attaches its trace to the same
|
||||||
|
// Langfuse parent. Closes the multi-call observability gap: a
|
||||||
|
// /v1/iterate session shows in Langfuse as one trace with N child
|
||||||
|
// chat spans, instead of N+1 disconnected traces.
|
||||||
|
func (h *handlers) chatCaller() validator.ChatCaller {
|
||||||
|
return func(ctx context.Context, system, user, _, model string, temp *float64, maxTokens int, traceID string) (string, error) {
|
||||||
|
messages := make([]map[string]string, 0, 2)
|
||||||
|
if system != "" {
|
||||||
|
messages = append(messages, map[string]string{"role": "system", "content": system})
|
||||||
|
}
|
||||||
|
messages = append(messages, map[string]string{"role": "user", "content": user})
|
||||||
|
body := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": maxTokens,
|
||||||
|
}
|
||||||
|
if temp != nil {
|
||||||
|
body["temperature"] = *temp
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal chat req: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", h.chatdURL+"/chat", bytes.NewReader(buf))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("build chat req: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if traceID != "" {
|
||||||
|
req.Header.Set(shared.TraceIDHeader, traceID)
|
||||||
|
}
|
||||||
|
resp, err := h.chatClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("chat hop: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return "", fmt.Errorf("chat %d: %s", resp.StatusCode, trim(string(raw), 300))
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return "", fmt.Errorf("parse chat resp: %w", err)
|
||||||
|
}
|
||||||
|
return parsed.Content, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iterTracer adapts validator.Tracer → langfuse.Client.Span. Each
|
||||||
|
// iteration attempt becomes one Langfuse span on the parent trace
|
||||||
|
// with input={prompt, model, provider}, output={raw, verdict}.
|
||||||
|
// Operators reading Langfuse can scroll the iterate session and see
|
||||||
|
// the full retry chain — which prompt produced which raw, which
|
||||||
|
// validator verdict landed, which retry the model recovered on.
|
||||||
|
//
|
||||||
|
// Returns the span id so it lands in IterateAttempt.SpanID — the
|
||||||
|
// /v1/iterate response carries that id back to the caller for log
|
||||||
|
// correlation.
|
||||||
|
//
|
||||||
|
// Errors are silent (best-effort posture per package langfuse). A
|
||||||
|
// dropped span never blocks the iterate loop.
|
||||||
|
func (h *handlers) iterTracer(ctx context.Context) validator.Tracer {
|
||||||
|
return func(s validator.AttemptSpan) string {
|
||||||
|
level := "DEFAULT"
|
||||||
|
if s.Verdict.Kind != "accepted" {
|
||||||
|
level = "WARNING"
|
||||||
|
}
|
||||||
|
return h.lf.Span(ctx, langfuse.SpanInput{
|
||||||
|
TraceID: s.TraceID,
|
||||||
|
Name: fmt.Sprintf("iterate.attempt[%d]", s.Iteration),
|
||||||
|
Input: map[string]any{
|
||||||
|
"iteration": s.Iteration,
|
||||||
|
"model": s.Model,
|
||||||
|
"provider": s.Provider,
|
||||||
|
"prompt": trim(s.Prompt, 4000),
|
||||||
|
},
|
||||||
|
Output: map[string]any{
|
||||||
|
"verdict": s.Verdict.Kind,
|
||||||
|
"error": s.Verdict.Error,
|
||||||
|
"raw": trim(s.Raw, 4000),
|
||||||
|
},
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"verdict_error": s.Verdict.Error,
|
||||||
|
},
|
||||||
|
StartTime: s.StartTime,
|
||||||
|
EndTime: s.EndTime,
|
||||||
|
Level: level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFallbackTraceID generates a time-ordered hex id used when no
|
||||||
|
// upstream trace id was forwarded AND the langfuse middleware didn't
|
||||||
|
// mint one (Langfuse unconfigured). Same shape Langfuse accepts so
|
||||||
|
// future Langfuse-enabled deployments don't break correlation. Avoids
|
||||||
|
// pulling in a uuid crate dep; nanosecond precision + a randomness
|
||||||
|
// suffix is unique enough at the rates iterate runs.
|
||||||
|
func newFallbackTraceID() string {
|
||||||
|
ns := time.Now().UTC().UnixNano()
|
||||||
|
rand := uint32(time.Now().UnixNano() % (1 << 32))
|
||||||
|
return fmt.Sprintf("%016x-%08x", ns, rand)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||||
|
slog.Error("encode", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trim(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readSessionLog parses the JSONL session log into row maps. Used by
|
||||||
|
// the iterate-handler tests to verify per-session entries.
|
||||||
|
func readSessionLog(t *testing.T, path string) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open session log: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []map[string]any
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 0, 1<<16), 1<<24)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var row map[string]any
|
||||||
|
if err := json.Unmarshal(line, &row); err != nil {
|
||||||
|
t.Fatalf("parse session log line: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestRouter builds the validatord router with an explicit lookup
|
||||||
|
// + a fake chatd URL. Tests that exercise /iterate need a live mock
|
||||||
|
// chatd (constructed inline per-test).
|
||||||
|
func newTestRouter(lookup validator.WorkerLookup, chatdURL string) http.Handler {
|
||||||
|
h := newTestHandlers(lookup, chatdURL, nil)
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestHandlers exposes the underlying handlers struct so tests can
|
||||||
|
// inspect post-call state (e.g. session log contents). sessionLog is
|
||||||
|
// optional — pass nil to disable the session log.
|
||||||
|
func newTestHandlers(lookup validator.WorkerLookup, chatdURL string, sessionLog *validator.SessionLogger) *handlers {
|
||||||
|
return &handlers{
|
||||||
|
lookup: lookup,
|
||||||
|
chatdURL: chatdURL,
|
||||||
|
chatClient: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
iterCfg: validator.IterateConfig{
|
||||||
|
DefaultMaxIterations: 3,
|
||||||
|
DefaultMaxTokens: 4096,
|
||||||
|
},
|
||||||
|
sessionLog: sessionLog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── /validate ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestValidate_RejectsUnknownKind(t *testing.T) {
|
||||||
|
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "")
|
||||||
|
body := []byte(`{"kind":"unknown","artifact":{}}`)
|
||||||
|
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for unknown kind, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_RejectsMissingArtifact(t *testing.T) {
|
||||||
|
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "")
|
||||||
|
body := []byte(`{"kind":"playbook"}`)
|
||||||
|
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for missing artifact, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_PlaybookHappyPath(t *testing.T) {
|
||||||
|
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "")
|
||||||
|
body := []byte(`{
|
||||||
|
"kind": "playbook",
|
||||||
|
"artifact": {
|
||||||
|
"operation": "fill: Welder x2 in Toledo, OH",
|
||||||
|
"endorsed_names": ["W-1","W-2"],
|
||||||
|
"target_count": 2,
|
||||||
|
"fingerprint": "abc123"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var report validator.Report
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &report); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if report.ElapsedMs < 0 {
|
||||||
|
t.Errorf("elapsed_ms negative: %d", report.ElapsedMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_PlaybookSchemaErrorReturns422(t *testing.T) {
|
||||||
|
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "")
|
||||||
|
body := []byte(`{
|
||||||
|
"kind": "playbook",
|
||||||
|
"artifact": {
|
||||||
|
"operation": "wrong_prefix: foo",
|
||||||
|
"endorsed_names": ["a"],
|
||||||
|
"fingerprint": "x"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("expected 422, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var ve validator.ValidationError
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &ve); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if ve.Kind != validator.ErrSchema {
|
||||||
|
t.Errorf("kind = %v, want schema", ve.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_FillRoutesThroughLookup(t *testing.T) {
|
||||||
|
city := "Toledo"
|
||||||
|
lookup := validator.NewInMemoryWorkerLookup([]validator.WorkerRecord{
|
||||||
|
{CandidateID: "W-1", Name: "Ada", Status: "active", City: &city},
|
||||||
|
})
|
||||||
|
r := newTestRouter(lookup, "")
|
||||||
|
|
||||||
|
// Candidate that doesn't exist in lookup → consistency failure.
|
||||||
|
body := []byte(`{
|
||||||
|
"kind": "fill",
|
||||||
|
"artifact": {
|
||||||
|
"fills": [{"candidate_id":"W-PHANTOM","name":"Nobody"}]
|
||||||
|
},
|
||||||
|
"context": {"target_count": 1, "city": "Toledo", "client_id": "C-1"}
|
||||||
|
}`)
|
||||||
|
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("expected 422 for phantom candidate, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_ContextMergedIntoArtifactContext(t *testing.T) {
|
||||||
|
// _context.target_count from the request `context` block must
|
||||||
|
// reach the FillValidator's completeness check. Without the
|
||||||
|
// merge, target_count would default to 0 and any non-empty fills
|
||||||
|
// list would fail Completeness.
|
||||||
|
city := "Toledo"
|
||||||
|
role := "Welder"
|
||||||
|
lookup := validator.NewInMemoryWorkerLookup([]validator.WorkerRecord{
|
||||||
|
{CandidateID: "W-1", Name: "Ada", Status: "active", City: &city, Role: &role},
|
||||||
|
})
|
||||||
|
r := newTestRouter(lookup, "")
|
||||||
|
body := []byte(`{
|
||||||
|
"kind": "fill",
|
||||||
|
"artifact": {"fills":[{"candidate_id":"W-1","name":"Ada"}]},
|
||||||
|
"context": {"target_count": 1, "city": "Toledo", "role": "Welder", "client_id": "C-1"}
|
||||||
|
}`)
|
||||||
|
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 with context merged, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── /iterate ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// fakeChatd returns a stand-in chatd HTTP server that emits the given
|
||||||
|
// content string for every /chat call. Caller closes the server.
|
||||||
|
func fakeChatd(t *testing.T, content string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
mux := chi.NewRouter()
|
||||||
|
mux.Post("/chat", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"model": "test-model",
|
||||||
|
"content": content,
|
||||||
|
"provider": "test",
|
||||||
|
"latency_ms": 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return httptest.NewServer(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIterate_RejectsMissingFields(t *testing.T) {
|
||||||
|
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "")
|
||||||
|
body := []byte(`{"kind":"playbook","prompt":"x"}`) // missing provider+model
|
||||||
|
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIterate_HappyPath_ReturnsAcceptedArtifact(t *testing.T) {
|
||||||
|
server := fakeChatd(t, `{"operation":"fill: Welder x1 in Toledo, OH","endorsed_names":["W-1"],"target_count":1,"fingerprint":"abc"}`)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), server.URL)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"kind": "playbook",
|
||||||
|
"prompt": "produce a playbook artifact",
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "qwen3.5:latest",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp validator.IterateResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Iterations != 1 {
|
||||||
|
t.Errorf("iterations = %d, want 1", resp.Iterations)
|
||||||
|
}
|
||||||
|
if resp.Artifact["operation"] != "fill: Welder x1 in Toledo, OH" {
|
||||||
|
t.Errorf("artifact.operation: %v", resp.Artifact["operation"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIterate_MaxIterReturns422WithHistory(t *testing.T) {
|
||||||
|
// Always returns a no-JSON response, so iterate exhausts retries.
|
||||||
|
server := fakeChatd(t, "no json here, just prose")
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), server.URL)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"kind": "playbook",
|
||||||
|
"prompt": "produce X",
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "x",
|
||||||
|
"max_iterations": 2,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("expected 422, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var fail validator.IterateFailure
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &fail); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if fail.Iterations != 2 {
|
||||||
|
t.Errorf("iterations = %d, want 2", fail.Iterations)
|
||||||
|
}
|
||||||
|
for _, h := range fail.History {
|
||||||
|
if h.Status.Kind != "no_json" {
|
||||||
|
t.Errorf("expected all attempts to be no_json, got %v", h.Status.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIterate_WritesSessionRowOnSuccess locks the offline-analysis
|
||||||
|
// contract: every successful /iterate call appends one SessionRecord
|
||||||
|
// to the configured JSONL with grounded_in_roster=true (because the
|
||||||
|
// model emitted real candidate IDs that exist in the roster).
|
||||||
|
func TestIterate_WritesSessionRowOnSuccess(t *testing.T) {
|
||||||
|
city, role := "Toledo", "Welder"
|
||||||
|
lookup := validator.NewInMemoryWorkerLookup([]validator.WorkerRecord{
|
||||||
|
{CandidateID: "W-1", Name: "Ada", Status: "active", City: &city, Role: &role},
|
||||||
|
})
|
||||||
|
server := fakeChatd(t, `{"fills":[{"candidate_id":"W-1","name":"Ada"}]}`)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
logPath := filepath.Join(dir, "sessions.jsonl")
|
||||||
|
h := newTestHandlers(lookup, server.URL, validator.NewSessionLogger(logPath))
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"kind": "fill",
|
||||||
|
"prompt": "produce fill",
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "qwen3.5:latest",
|
||||||
|
"context": map[string]any{"target_count": 1, "city": "Toledo", "role": "Welder", "client_id": "C-1"},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := readSessionLog(t, logPath)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("expected 1 session row, got %d", len(rows))
|
||||||
|
}
|
||||||
|
row := rows[0]
|
||||||
|
if row["schema"] != validator.SessionRecordSchema {
|
||||||
|
t.Errorf("schema: %v", row["schema"])
|
||||||
|
}
|
||||||
|
if row["final_verdict"] != "accepted" {
|
||||||
|
t.Errorf("final_verdict: %v", row["final_verdict"])
|
||||||
|
}
|
||||||
|
if row["grounded_in_roster"] != true {
|
||||||
|
t.Errorf("grounded_in_roster: expected true (W-1 is in roster), got %v", row["grounded_in_roster"])
|
||||||
|
}
|
||||||
|
if row["kind"] != "fill" {
|
||||||
|
t.Errorf("kind: %v", row["kind"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIterate_GroundedFalseWhenArtifactReferencesPhantom is the
|
||||||
|
// load-bearing forensic property J flagged 2026-05-02: phantom
|
||||||
|
// worker IDs in the final accepted artifact must surface as
|
||||||
|
// grounded_in_roster=false in the session log. This shouldn't happen
|
||||||
|
// in practice (FillValidator catches phantoms before they reach this
|
||||||
|
// row), but the explicit check guards against a future bug where the
|
||||||
|
// validator weakens or a different validator path skips the roster
|
||||||
|
// check. Defense-in-depth at the observability layer.
|
||||||
|
func TestIterate_GroundedFalseWhenArtifactReferencesPhantom(t *testing.T) {
|
||||||
|
// Empty roster — every candidate ID is "phantom". Plus we use
|
||||||
|
// the playbook kind so the validator doesn't reject the artifact
|
||||||
|
// upfront (PlaybookValidator doesn't consult the roster), letting
|
||||||
|
// the artifact reach the session log. We then build a custom
|
||||||
|
// rosterCheckFor "fill" closure and exercise it directly.
|
||||||
|
lookup := validator.NewInMemoryWorkerLookup(nil)
|
||||||
|
h := newTestHandlers(lookup, "", nil)
|
||||||
|
check := h.rosterCheckFor("fill")
|
||||||
|
if check == nil {
|
||||||
|
t.Fatal("expected non-nil checker for fill kind")
|
||||||
|
}
|
||||||
|
got := check(map[string]any{
|
||||||
|
"fills": []any{
|
||||||
|
map[string]any{"candidate_id": "W-PHANTOM-NEVER-EXISTS"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got == nil || *got {
|
||||||
|
t.Errorf("expected grounded=false for phantom candidate, got %v", got)
|
||||||
|
}
|
||||||
|
// Other kinds: no roster concept → checker returns nil.
|
||||||
|
if h.rosterCheckFor("email") != nil {
|
||||||
|
t.Error("email kind should not have a roster checker")
|
||||||
|
}
|
||||||
|
if h.rosterCheckFor("playbook") != nil {
|
||||||
|
t.Error("playbook kind should not have a roster checker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIterate_WritesSessionRowOnFailure: max-iter exhaustion still
|
||||||
|
// produces a row (omits artifact, captures the retry chain).
|
||||||
|
func TestIterate_WritesSessionRowOnFailure(t *testing.T) {
|
||||||
|
server := fakeChatd(t, "no json here, just prose")
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
logPath := filepath.Join(dir, "sessions.jsonl")
|
||||||
|
h := newTestHandlers(validator.NewInMemoryWorkerLookup(nil), server.URL,
|
||||||
|
validator.NewSessionLogger(logPath))
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"kind": "playbook",
|
||||||
|
"prompt": "produce X",
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "x",
|
||||||
|
"max_iterations": 2,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("expected 422, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := readSessionLog(t, logPath)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("expected 1 session row, got %d", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0]["final_verdict"] != "max_iter_exhausted" {
|
||||||
|
t.Errorf("final_verdict: %v", rows[0]["final_verdict"])
|
||||||
|
}
|
||||||
|
if _, hasArtifact := rows[0]["artifact"]; hasArtifact {
|
||||||
|
t.Errorf("artifact should be omitted on failure: %v", rows[0]["artifact"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIterate_ChatdDownReturns502(t *testing.T) {
|
||||||
|
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "http://127.0.0.1:1") // unroutable
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"kind": "playbook",
|
||||||
|
"prompt": "X",
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "x",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("expected 502, got %d (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
-17
@@ -17,6 +17,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -62,7 +63,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := shared.Run("vectord", cfg.Vectord.Bind, h.register); err != nil {
|
if err := shared.Run("vectord", cfg.Vectord.Bind, h.register, cfg.Auth); err != nil {
|
||||||
slog.Error("server", "err", err)
|
slog.Error("server", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,73 @@ func main() {
|
|||||||
type handlers struct {
|
type handlers struct {
|
||||||
reg *vectord.Registry
|
reg *vectord.Registry
|
||||||
persist *vectord.Persistor // nil when persistence is disabled
|
persist *vectord.Persistor // nil when persistence is disabled
|
||||||
|
|
||||||
|
// saversMu guards lazy initialization of per-index save tasks.
|
||||||
|
// Each task coalesces synchronous Save calls into single-flight
|
||||||
|
// async saves so high-write-rate indexes (playbook_memory under
|
||||||
|
// multitier_100k load) don't pay one MinIO PUT per Add. See the
|
||||||
|
// saveTask docstring for the coalescing semantics.
|
||||||
|
saversMu sync.Mutex
|
||||||
|
savers map[string]*saveTask
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveTask coalesces saves for one index into a single-flight async
|
||||||
|
// goroutine. While a save is in-flight, additional triggers mark
|
||||||
|
// "pending" — the in-flight goroutine reruns the save after it
|
||||||
|
// finishes, collapsing N concurrent triggers into at most 2 saves
|
||||||
|
// (the current in-flight + one catch-up).
|
||||||
|
//
|
||||||
|
// Why: pre-2026-05-01 each successful Add called Persistor.Save
|
||||||
|
// synchronously inside the request handler. For playbook_memory at
|
||||||
|
// 1900-entry / 768-d, Encode + MinIO PUT cost 100-300ms. With 50
|
||||||
|
// concurrent writers, end-to-end Add latency hit 2-2.5s purely from
|
||||||
|
// save serialization (Save takes the index RLock for Encode, which
|
||||||
|
// blocks new Adds taking the Lock).
|
||||||
|
//
|
||||||
|
// Trade-off: RPO. Add now returns OK before the save completes, so
|
||||||
|
// a crash can lose up to ~1 save's worth of data. Acceptable for
|
||||||
|
// the playbook-memory shape (learning loop — lost trace re-recorded
|
||||||
|
// on next run) and consistent with ADR-005's fail-open posture.
|
||||||
|
type saveTask struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
inflight bool
|
||||||
|
pending bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// trigger schedules a save. If a save is already in-flight, marks
|
||||||
|
// pending and returns. If none in-flight, starts a goroutine that
|
||||||
|
// runs save and any queued pending saves.
|
||||||
|
//
|
||||||
|
// save is the actual save operation (parameterized for testability).
|
||||||
|
// Errors are logged via slog and not returned — same fail-open
|
||||||
|
// posture as the prior synchronous saveAfter.
|
||||||
|
func (s *saveTask) trigger(save func() error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.inflight {
|
||||||
|
s.pending = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.inflight = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
if err := save(); err != nil {
|
||||||
|
slog.Warn("persist save", "err", err)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.pending {
|
||||||
|
s.inflight = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pending = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
// Loop: re-run save to capture changes that arrived
|
||||||
|
// while we were saving.
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// rehydrate enumerates persisted indexes and loads each into the
|
// rehydrate enumerates persisted indexes and loads each into the
|
||||||
@@ -103,19 +171,38 @@ func (h *handlers) rehydrate(ctx context.Context) (int, error) {
|
|||||||
return loaded, nil
|
return loaded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveAfter is the post-write persistence hook. Logs-not-fatal:
|
// saveAfter triggers a coalesced async persistence for the index.
|
||||||
// in-memory state is the source of truth in flight; a failed save
|
// In-memory state is the source of truth in flight; a failed save
|
||||||
// gets re-attempted on the next mutation, and the operator log
|
// re-runs on the next mutation, and the operator log shows the
|
||||||
// shows the storaged outage.
|
// storaged outage.
|
||||||
|
//
|
||||||
|
// Coalescing semantics (added 2026-05-01 after multitier_100k
|
||||||
|
// follow-up): rapid concurrent writes collapse into at most two
|
||||||
|
// MinIO PUTs per index (current + one catch-up), instead of one
|
||||||
|
// per Add. See the saveTask docstring.
|
||||||
func (h *handlers) saveAfter(idx *vectord.Index) {
|
func (h *handlers) saveAfter(idx *vectord.Index) {
|
||||||
if h.persist == nil {
|
if h.persist == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
name := idx.Params().Name
|
||||||
|
h.saversMu.Lock()
|
||||||
|
if h.savers == nil {
|
||||||
|
h.savers = make(map[string]*saveTask)
|
||||||
|
}
|
||||||
|
s, ok := h.savers[name]
|
||||||
|
if !ok {
|
||||||
|
s = &saveTask{}
|
||||||
|
h.savers[name] = s
|
||||||
|
}
|
||||||
|
h.saversMu.Unlock()
|
||||||
|
s.trigger(func() error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := h.persist.Save(ctx, idx); err != nil {
|
if err := h.persist.Save(ctx, idx); err != nil {
|
||||||
slog.Warn("persist save", "name", idx.Params().Name, "err", err)
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteAfter mirrors saveAfter for the Delete path.
|
// deleteAfter mirrors saveAfter for the Delete path.
|
||||||
@@ -137,6 +224,7 @@ func (h *handlers) register(r chi.Router) {
|
|||||||
r.Delete("/vectors/index/{name}", h.handleDelete)
|
r.Delete("/vectors/index/{name}", h.handleDelete)
|
||||||
r.Post("/vectors/index/{name}/add", h.handleAdd)
|
r.Post("/vectors/index/{name}/add", h.handleAdd)
|
||||||
r.Post("/vectors/index/{name}/search", h.handleSearch)
|
r.Post("/vectors/index/{name}/search", h.handleSearch)
|
||||||
|
r.Post("/vectors/index/{name}/merge", h.handleMerge)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createRequest mirrors POST /vectors/index body.
|
// createRequest mirrors POST /vectors/index body.
|
||||||
@@ -274,22 +362,19 @@ func (h *handlers) handleAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Pre-validation above is exhaustive (id, dim, finite, zero-norm),
|
||||||
|
// so BatchAdd takes the write-lock once and pushes the whole batch
|
||||||
|
// into coder/hnsw via one variadic Graph.Add. Saves N-1 lock
|
||||||
|
// acquisitions per HTTP batch.
|
||||||
|
batch := make([]vectord.BatchItem, len(req.Items))
|
||||||
for j, it := range req.Items {
|
for j, it := range req.Items {
|
||||||
if err := idx.Add(it.ID, it.Vector, it.Metadata); err != nil {
|
batch[j] = vectord.BatchItem{ID: it.ID, Vector: it.Vector, Metadata: it.Metadata}
|
||||||
// Vector-validation errors (NaN/Inf, zero-norm under
|
|
||||||
// cosine) only surface here; pre-validation is intentional
|
|
||||||
// minimal scope (id + dim only).
|
|
||||||
if errors.Is(err, vectord.ErrDimensionMismatch) ||
|
|
||||||
strings.Contains(err.Error(), "non-finite") ||
|
|
||||||
strings.Contains(err.Error(), "zero-norm") {
|
|
||||||
http.Error(w, "items["+strconv.Itoa(j)+"]: "+err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
slog.Error("add", "name", name, "id", it.ID, "err", err)
|
if err := idx.BatchAdd(batch); err != nil {
|
||||||
|
slog.Error("batch add", "name", name, "err", err)
|
||||||
http.Error(w, "internal", http.StatusInternalServerError)
|
http.Error(w, "internal", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// One save per batch (post-loop), not per item. Per scrum
|
// One save per batch (post-loop), not per item. Per scrum
|
||||||
// O-W4-style discipline: HTTP-batch boundary is the natural unit.
|
// O-W4-style discipline: HTTP-batch boundary is the natural unit.
|
||||||
h.saveAfter(idx)
|
h.saveAfter(idx)
|
||||||
@@ -327,6 +412,119 @@ func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, searchResponse{Results: hits})
|
writeJSON(w, http.StatusOK, searchResponse{Results: hits})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeRequest body for POST /vectors/index/{name}/merge:
|
||||||
|
//
|
||||||
|
// {"dest": "workers", "clear_source": true}
|
||||||
|
//
|
||||||
|
// "name" in the URL is the SOURCE index. Every item from source is
|
||||||
|
// added to dest with the same id + vector + metadata. clear_source=
|
||||||
|
// true (default false) deletes each successfully-merged item from
|
||||||
|
// the source after add — leaves source empty when merge succeeds in
|
||||||
|
// full. clear_source=false leaves source untouched (useful for dry-
|
||||||
|
// run or "copy not move" semantics).
|
||||||
|
//
|
||||||
|
// Closes OPEN #1: periodic fresh→main index merge. The fresh_workers
|
||||||
|
// two-tier index pattern grows monotonically; this endpoint is the
|
||||||
|
// drain that operators (or a cron) call when fresh_workers crosses
|
||||||
|
// the operational ceiling (~500 items per the original gating
|
||||||
|
// criterion).
|
||||||
|
//
|
||||||
|
// Returns counts: {merged, skipped_already_present, failed, length_dest, length_source}.
|
||||||
|
type mergeRequest struct {
|
||||||
|
Dest string `json:"dest"`
|
||||||
|
ClearSource bool `json:"clear_source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mergeResponse struct {
|
||||||
|
Merged int `json:"merged"`
|
||||||
|
SkippedAlreadyPresent int `json:"skipped_already_present"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
LengthDest int `json:"length_dest"`
|
||||||
|
LengthSource int `json:"length_source"`
|
||||||
|
FirstError string `json:"first_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleMerge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := chi.URLParam(r, "name")
|
||||||
|
src, err := h.reg.Get(name)
|
||||||
|
if errors.Is(err, vectord.ErrIndexNotFound) {
|
||||||
|
http.Error(w, "source not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
// Per scrum post_role_gate_v1 (Opus): non-ErrIndexNotFound
|
||||||
|
// errors must NOT fall through — src would be nil and the
|
||||||
|
// next deref panics.
|
||||||
|
slog.Error("merge: get source", "name", name, "err", err)
|
||||||
|
http.Error(w, "internal", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req mergeRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Dest == "" || req.Dest == name {
|
||||||
|
http.Error(w, "dest must be set and differ from source", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dest, err := h.reg.Get(req.Dest)
|
||||||
|
if errors.Is(err, vectord.ErrIndexNotFound) {
|
||||||
|
http.Error(w, "dest not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
// Same fix as source — non-ErrIndexNotFound dest errors
|
||||||
|
// can't reach the merge body with a nil dest pointer.
|
||||||
|
slog.Error("merge: get dest", "name", req.Dest, "err", err)
|
||||||
|
http.Error(w, "internal", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Dimension match is non-negotiable — silently moving a 768-d
|
||||||
|
// vector into a 384-d index would corrupt search forever.
|
||||||
|
if src.Params().Dimension != dest.Params().Dimension {
|
||||||
|
http.Error(w, "source/dest dimension mismatch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := mergeResponse{}
|
||||||
|
for _, id := range src.IDs() {
|
||||||
|
vec, meta, ok := src.Lookup(id)
|
||||||
|
if !ok {
|
||||||
|
// Vanished between IDs() snapshot and Lookup — concurrent
|
||||||
|
// delete. Treat as skip; not a failure.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip if dest already has the id (idempotent re-runs don't
|
||||||
|
// double-add). Operators expect "merge again" to be safe.
|
||||||
|
if _, _, exists := dest.Lookup(id); exists {
|
||||||
|
resp.SkippedAlreadyPresent++
|
||||||
|
if req.ClearSource {
|
||||||
|
src.Delete(id)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := dest.Add(id, vec, meta); err != nil {
|
||||||
|
resp.Failed++
|
||||||
|
if resp.FirstError == "" {
|
||||||
|
resp.FirstError = "add " + id + ": " + err.Error()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Merged++
|
||||||
|
if req.ClearSource {
|
||||||
|
src.Delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Persist both. Saving in-line under the merge endpoint is fine
|
||||||
|
// here because operators run this as a deliberate one-shot job,
|
||||||
|
// not a hot-path batch.
|
||||||
|
h.saveAfter(dest)
|
||||||
|
if req.ClearSource {
|
||||||
|
h.saveAfter(src)
|
||||||
|
}
|
||||||
|
resp.LengthDest = dest.Len()
|
||||||
|
resp.LengthSource = src.Len()
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
// decodeJSON reads + decodes a JSON body with a body-size cap.
|
// decodeJSON reads + decodes a JSON body with a body-size cap.
|
||||||
// Returns false (and writes the error response) on failure.
|
// Returns false (and writes the error response) on failure.
|
||||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/vectord"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Closes R-005 for vectord: cmd-level tests for the 6 routes.
|
||||||
|
// Persistence-disabled mode (h.persist == nil) is the test config —
|
||||||
|
// keeps tests pure-in-memory; persistence is covered by g1p_smoke +
|
||||||
|
// proof GOLAKE-070.
|
||||||
|
|
||||||
|
func mountedRouter() chi.Router {
|
||||||
|
h := &handlers{reg: vectord.NewRegistry()}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutesMounted(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
want := map[string]bool{
|
||||||
|
"POST /vectors/index": false,
|
||||||
|
"GET /vectors/index": false,
|
||||||
|
"GET /vectors/index/{name}": false,
|
||||||
|
"DELETE /vectors/index/{name}": false,
|
||||||
|
"POST /vectors/index/{name}/add": false,
|
||||||
|
"POST /vectors/index/{name}/search": false,
|
||||||
|
"POST /vectors/index/{name}/merge": false,
|
||||||
|
}
|
||||||
|
chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||||
|
key := method + " " + route
|
||||||
|
if _, ok := want[key]; ok {
|
||||||
|
want[key] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
for sig, found := range want {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected route %q mounted", sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCreate_HappyPath_201(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index", "application/json",
|
||||||
|
strings.NewReader(`{"name":"test_idx","dimension":4}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Errorf("expected 201 on create, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCreate_MissingDim_400(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index", "application/json",
|
||||||
|
strings.NewReader(`{"name":"missing_dim"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on missing dim, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCreate_MalformedJSON_400(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index", "application/json",
|
||||||
|
strings.NewReader("not json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on malformed, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCreate_BodyTooLarge(t *testing.T) {
|
||||||
|
// 4xx range — see embedd's TestHandleEmbed_BodyTooLarge for the
|
||||||
|
// 413-vs-400 unwrap nuance. Contract is "client error, fails loud."
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
big := bytes.Repeat([]byte("x"), maxRequestBytes+(1<<20))
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index", "application/json", bytes.NewReader(big))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||||
|
t.Errorf("expected 4xx on oversize, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetIndex_NotFound_404(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/vectors/index/nonexistent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdd_IndexNotFound_404(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index/missing/add", "application/json",
|
||||||
|
strings.NewReader(`{"items":[{"id":"v1","vector":[1,2,3,4]}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 on add to missing index, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdd_EmptyItems_400(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Create index first.
|
||||||
|
http.Post(srv.URL+"/vectors/index", "application/json",
|
||||||
|
strings.NewReader(`{"name":"empty_test","dimension":4}`))
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index/empty_test/add", "application/json",
|
||||||
|
strings.NewReader(`{"items":[]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on empty items, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdd_DimMismatch_400(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
http.Post(srv.URL+"/vectors/index", "application/json",
|
||||||
|
strings.NewReader(`{"name":"dim_test","dimension":3}`))
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index/dim_test/add", "application/json",
|
||||||
|
strings.NewReader(`{"items":[{"id":"x","vector":[1,2,3,4]}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on dim mismatch, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdd_EmptyID_400(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
http.Post(srv.URL+"/vectors/index", "application/json",
|
||||||
|
strings.NewReader(`{"name":"id_test","dimension":4}`))
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index/id_test/add", "application/json",
|
||||||
|
strings.NewReader(`{"items":[{"id":"","vector":[1,2,3,4]}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on empty id, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSearch_IndexNotFound_404(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index/missing/search", "application/json",
|
||||||
|
strings.NewReader(`{"vector":[1,2,3,4],"k":5}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 on search of missing index, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDelete_NotFound_404(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/vectors/index/missing", nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DELETE: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 deleting missing index, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleList_EmptyShape(t *testing.T) {
|
||||||
|
r := mountedRouter()
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/vectors/index")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMerge end-to-end via mountedRouter (no external HTTP):
|
||||||
|
// create source + dest indexes, populate source, merge with
|
||||||
|
// clear_source=true, assert dest gained the items, source emptied.
|
||||||
|
// Closes OPEN #1 — locks the merge contract at unit level so a
|
||||||
|
// future regression on the IDs/Lookup/Add/Delete chain fails here
|
||||||
|
// before any operator hits "merge again" and silently moves nothing.
|
||||||
|
func TestHandleMerge_HappyPath_DrainAndClear(t *testing.T) {
|
||||||
|
h := &handlers{reg: vectord.NewRegistry()}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Create both indexes (4-d for test simplicity).
|
||||||
|
for _, name := range []string{"fresh_test", "main_test"} {
|
||||||
|
body := `{"name":"` + name + `","dimension":4,"distance":"cosine"}`
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index", "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create %s: %v", name, err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate fresh_test with 3 items.
|
||||||
|
addBody := `{"items":[
|
||||||
|
{"id":"f-1","vector":[1,0,0,0],"metadata":{"name":"fresh-001"}},
|
||||||
|
{"id":"f-2","vector":[0,1,0,0],"metadata":{"name":"fresh-002"}},
|
||||||
|
{"id":"f-3","vector":[0,0,1,0],"metadata":{"name":"fresh-003"}}
|
||||||
|
]}`
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index/fresh_test/add", "application/json", strings.NewReader(addBody))
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("add to fresh_test: status=%d err=%v", resp.StatusCode, err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Pre-seed main_test with one item that ALSO exists in fresh
|
||||||
|
// (collision) so we exercise the skipped_already_present path.
|
||||||
|
preBody := `{"items":[{"id":"f-1","vector":[1,0,0,0],"metadata":{"name":"main-collision"}}]}`
|
||||||
|
resp, err = http.Post(srv.URL+"/vectors/index/main_test/add", "application/json", strings.NewReader(preBody))
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("add collision to main_test: status=%d err=%v", resp.StatusCode, err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Merge fresh_test → main_test, clearing source.
|
||||||
|
mergeBody := `{"dest":"main_test","clear_source":true}`
|
||||||
|
resp, err = http.Post(srv.URL+"/vectors/index/fresh_test/merge", "application/json", strings.NewReader(mergeBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 on merge, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var out mergeResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode merge resp: %v", err)
|
||||||
|
}
|
||||||
|
if out.Merged != 2 {
|
||||||
|
t.Errorf("expected 2 merged (f-2 + f-3), got %d", out.Merged)
|
||||||
|
}
|
||||||
|
if out.SkippedAlreadyPresent != 1 {
|
||||||
|
t.Errorf("expected 1 skipped (f-1 collision), got %d", out.SkippedAlreadyPresent)
|
||||||
|
}
|
||||||
|
if out.LengthSource != 0 {
|
||||||
|
t.Errorf("expected source emptied, got len=%d", out.LengthSource)
|
||||||
|
}
|
||||||
|
if out.LengthDest != 3 {
|
||||||
|
t.Errorf("expected dest len=3 after merge, got %d", out.LengthDest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMerge_DimensionMismatch_400(t *testing.T) {
|
||||||
|
h := &handlers{reg: vectord.NewRegistry()}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
for _, c := range []struct{ name string; dim int }{
|
||||||
|
{"src_4d", 4},
|
||||||
|
{"dst_8d", 8},
|
||||||
|
} {
|
||||||
|
body := `{"name":"` + c.name + `","dimension":` + strconv.Itoa(c.dim) + `,"distance":"cosine"}`
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index", "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create %s: %v", c.name, err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index/src_4d/merge", "application/json",
|
||||||
|
strings.NewReader(`{"dest":"dst_8d"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 on dim mismatch, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMerge_DestNotFound_404(t *testing.T) {
|
||||||
|
h := &handlers{reg: vectord.NewRegistry()}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
body := `{"name":"only_src","dimension":4}`
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index", "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
resp, err = http.Post(srv.URL+"/vectors/index/only_src/merge", "application/json",
|
||||||
|
strings.NewReader(`{"dest":"missing_dest"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 for missing dest, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMerge_SameSourceDest_400(t *testing.T) {
|
||||||
|
h := &handlers{reg: vectord.NewRegistry()}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h.register(r)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
body := `{"name":"self","dimension":4}`
|
||||||
|
resp, err := http.Post(srv.URL+"/vectors/index", "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
resp, err = http.Post(srv.URL+"/vectors/index/self/merge", "application/json",
|
||||||
|
strings.NewReader(`{"dest":"self"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for self-merge, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchK_DefaultsAndMax(t *testing.T) {
|
||||||
|
if defaultK <= 0 {
|
||||||
|
t.Errorf("defaultK = %d, must be > 0", defaultK)
|
||||||
|
}
|
||||||
|
if maxK < defaultK {
|
||||||
|
t.Errorf("maxK=%d < defaultK=%d", maxK, defaultK)
|
||||||
|
}
|
||||||
|
// Sanity bounds.
|
||||||
|
if maxK > 100_000 {
|
||||||
|
t.Errorf("maxK=%d unreasonably large", maxK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSaveTask_Coalesces locks the multitier_100k follow-up: a
|
||||||
|
// burst of triggers must collapse into at most 2 actual saves
|
||||||
|
// (the in-flight one + one catch-up). Without coalescing, every
|
||||||
|
// trigger would yield a save and concurrent writers would
|
||||||
|
// serialize on the index RLock during Encode (the original
|
||||||
|
// 1-2.5s tail-latency cause).
|
||||||
|
func TestSaveTask_Coalesces(t *testing.T) {
|
||||||
|
var (
|
||||||
|
s saveTask
|
||||||
|
saveCnt atomic.Int32
|
||||||
|
started = make(chan struct{}, 1)
|
||||||
|
release = make(chan struct{})
|
||||||
|
)
|
||||||
|
save := func() error {
|
||||||
|
// First save blocks until released so we can pile up
|
||||||
|
// triggers behind it. Subsequent saves return fast so the
|
||||||
|
// catch-up logic completes promptly.
|
||||||
|
n := saveCnt.Add(1)
|
||||||
|
if n == 1 {
|
||||||
|
started <- struct{}{}
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Trigger first save and wait for it to enter the blocked region.
|
||||||
|
s.trigger(save)
|
||||||
|
<-started
|
||||||
|
// Pile up triggers while the first is blocked. None of these
|
||||||
|
// should start their own goroutines — they should mark "pending".
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
s.trigger(save)
|
||||||
|
}
|
||||||
|
// Release the first save. The trigger logic should run ONE
|
||||||
|
// catch-up save for all 50 piled-up triggers, then return.
|
||||||
|
close(release)
|
||||||
|
// Wait for the goroutine to drain.
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
s.mu.Lock()
|
||||||
|
idle := !s.inflight && !s.pending
|
||||||
|
s.mu.Unlock()
|
||||||
|
if idle {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
got := saveCnt.Load()
|
||||||
|
if got != 2 {
|
||||||
|
t.Errorf("save count = %d, want 2 (one in-flight + one catch-up)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSaveTask_RunsOnce — single trigger fires exactly one save.
|
||||||
|
func TestSaveTask_RunsOnce(t *testing.T) {
|
||||||
|
var s saveTask
|
||||||
|
var n atomic.Int32
|
||||||
|
done := make(chan struct{})
|
||||||
|
s.trigger(func() error {
|
||||||
|
n.Add(1)
|
||||||
|
close(done)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("trigger goroutine never ran")
|
||||||
|
}
|
||||||
|
// Wait briefly for the goroutine to mark inflight=false.
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if got := n.Load(); got != 1 {
|
||||||
|
t.Errorf("save count = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSaveTask_LogsSaveError — a save error doesn't break the
|
||||||
|
// coalescing state machine; subsequent triggers still work.
|
||||||
|
func TestSaveTask_LogsSaveError(t *testing.T) {
|
||||||
|
var s saveTask
|
||||||
|
var n atomic.Int32
|
||||||
|
wantErr := errors.New("boom")
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
s.trigger(func() error {
|
||||||
|
defer wg.Done()
|
||||||
|
n.Add(1)
|
||||||
|
return wantErr
|
||||||
|
})
|
||||||
|
wg.Wait()
|
||||||
|
// State must reset so the next trigger fires another save.
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
wg.Add(1)
|
||||||
|
s.trigger(func() error {
|
||||||
|
defer wg.Done()
|
||||||
|
n.Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
wg.Wait()
|
||||||
|
if got := n.Load(); got != 2 {
|
||||||
|
t.Errorf("save count = %d, want 2 (failure must not stall the task)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# /etc/lakehouse/auth.env — inter-service auth token per ADR-006.
|
||||||
|
#
|
||||||
|
# Mode 0600, owned by the lakehouse user (or root if systemd reads it
|
||||||
|
# before dropping privileges via User=). Loaded by every daemon's
|
||||||
|
# systemd unit via EnvironmentFile=-/etc/lakehouse/auth.env (the `-`
|
||||||
|
# prefix means "missing file is OK" so loopback-only deploys can skip
|
||||||
|
# this entirely).
|
||||||
|
#
|
||||||
|
# When the daemon binds non-loopback (anything other than 127.0.0.0/8
|
||||||
|
# or ::1), AUTH_TOKEN MUST be set — otherwise shared.Run refuses to
|
||||||
|
# start (R-001 + R-007 mechanical gate). Loopback-only deploys can
|
||||||
|
# leave this empty.
|
||||||
|
#
|
||||||
|
# Token rotation (ADR-006 Decision 6.5):
|
||||||
|
# 1. Generate new secret
|
||||||
|
# 2. Set AUTH_SECONDARY_TOKEN to new secret while AUTH_TOKEN stays
|
||||||
|
# on old (lakehouse.toml [auth].secondary_tokens reads this)
|
||||||
|
# 3. Update every caller to use new secret
|
||||||
|
# 4. Promote: AUTH_TOKEN=<new>, clear AUTH_SECONDARY_TOKEN
|
||||||
|
# 5. Restart daemons (or SIGHUP once hot-reload lands)
|
||||||
|
|
||||||
|
AUTH_TOKEN=
|
||||||
|
# AUTH_SECONDARY_TOKEN= # only set during rotation windows
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# /etc/lakehouse/langfuse.env — Langfuse trace observability.
|
||||||
|
#
|
||||||
|
# Mode 0640, root:lakehouse. Loaded by every daemon's systemd unit
|
||||||
|
# via EnvironmentFile=-/etc/lakehouse/langfuse.env. The `-` prefix
|
||||||
|
# means "missing file is OK" — Langfuse is optional. If the file is
|
||||||
|
# missing or any of the three env vars below is empty, every daemon
|
||||||
|
# runs without Langfuse and with no logged warnings (best-effort).
|
||||||
|
#
|
||||||
|
# When all three are set, every authenticated HTTP request to every
|
||||||
|
# daemon emits one Langfuse trace + span, browseable at LANGFUSE_URL.
|
||||||
|
# /health is exempt to keep LB probes from polluting traces.
|
||||||
|
#
|
||||||
|
# Same env vars + same shape as the file the multi_coord_stress
|
||||||
|
# driver reads — operators ship one langfuse.env across the whole
|
||||||
|
# deploy.
|
||||||
|
|
||||||
|
LANGFUSE_URL=http://localhost:3001
|
||||||
|
LANGFUSE_PUBLIC_KEY=pk-lf-staffing
|
||||||
|
LANGFUSE_SECRET_KEY=sk-lf-staffing-secret
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# /etc/lakehouse/secrets-go.toml — per-bucket S3 credentials.
|
||||||
|
#
|
||||||
|
# Mode 0600, root-owned (storaged + queryd both need to read it; they
|
||||||
|
# run as the lakehouse user, so chgrp lakehouse + 0640 is fine if the
|
||||||
|
# group is restricted).
|
||||||
|
#
|
||||||
|
# Schema is one [s3.<bucket-logical-name>] block per bucket the
|
||||||
|
# storaged BucketRegistry should serve. G0 is single-bucket
|
||||||
|
# ("primary" — see cmd/storaged/main.go primaryBucket const). G2
|
||||||
|
# multi-bucket federation will add more entries here.
|
||||||
|
#
|
||||||
|
# This file is DELIBERATELY NOT in version control. Operators copy
|
||||||
|
# this template, fill in real credentials, and place at
|
||||||
|
# /etc/lakehouse/secrets-go.toml. The committed lakehouse.toml [s3]
|
||||||
|
# block has bucket=<logical-name> + endpoint=<url> + region=<region>
|
||||||
|
# — only the credentials live here.
|
||||||
|
|
||||||
|
[s3.primary]
|
||||||
|
access_key_id = "REPLACE_ME"
|
||||||
|
secret_access_key = "REPLACE_ME"
|
||||||
|
|
||||||
|
# Future G2 example — multiple buckets:
|
||||||
|
# [s3.archive]
|
||||||
|
# access_key_id = "..."
|
||||||
|
# secret_access_key = "..."
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go catalogd — Parquet manifest registry
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target lakehouse-storaged.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=lakehouse-storaged.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/catalogd -config /etc/lakehouse/lakehouse.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-catalogd
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go chatd — multi-provider LLM dispatcher
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
# Operator prereq: Ollama on localhost:11434 for the bare/ollama/
|
||||||
|
# providers; cloud providers (ollama_cloud, openrouter, opencode,
|
||||||
|
# kimi) read keys from /etc/lakehouse/<provider>.env per chatd
|
||||||
|
# config. Missing key files leave that provider unregistered (404
|
||||||
|
# at first call, never 503).
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/chatd -config /etc/lakehouse/lakehouse.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
# chatd reads provider key files via paths in lakehouse.toml [chatd]
|
||||||
|
# (ollama_cloud_key_file etc.) — each is its own EnvironmentFile so
|
||||||
|
# operators can rotate one provider without restarting others.
|
||||||
|
EnvironmentFile=-/etc/lakehouse/ollama_cloud.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/openrouter.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/opencode.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/kimi.env
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-chatd
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go embedd — text→vector via Ollama
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
# Operator prereq: Ollama running at the URL in lakehouse.toml
|
||||||
|
# [embedd].provider_url, with default_model loaded (e.g.
|
||||||
|
# `ollama pull nomic-embed-text-v2-moe`). Not a systemd unit we
|
||||||
|
# control; embedd surfaces unreachable-Ollama as 502 at request time.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/embedd -config /etc/lakehouse/lakehouse.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-embedd
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go gateway — single OpenAI-compat-shaped edge proxy
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target lakehouse-storaged.service lakehouse-catalogd.service lakehouse-ingestd.service lakehouse-queryd.service lakehouse-vectord.service lakehouse-embedd.service lakehouse-pathwayd.service lakehouse-observerd.service lakehouse-matrixd.service lakehouse-chatd.service
|
||||||
|
Wants=network-online.target lakehouse-storaged.service lakehouse-catalogd.service lakehouse-ingestd.service lakehouse-queryd.service lakehouse-vectord.service lakehouse-embedd.service lakehouse-pathwayd.service lakehouse-observerd.service lakehouse-matrixd.service lakehouse-chatd.service
|
||||||
|
# gateway is the public-facing edge — if any upstream is down, the
|
||||||
|
# proxy returns 502 at request time. Wants= (not Requires=) so a
|
||||||
|
# single upstream restart doesn't cascade-restart the gateway.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/gateway -config /etc/lakehouse/lakehouse.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-gateway
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go — all 11 daemons (gateway + 10 backing services)
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
# A single target operators can `systemctl start` / `stop` / `enable`
|
||||||
|
# instead of touching each daemon individually. Per-daemon units list
|
||||||
|
# this target in their [Install].WantedBy=, so `systemctl enable
|
||||||
|
# lakehouse-go.target` enables every daemon's unit, and a target
|
||||||
|
# start cascades through Wants=.
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go ingestd — CSV → Parquet → catalogd registration
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target lakehouse-storaged.service lakehouse-catalogd.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=lakehouse-storaged.service lakehouse-catalogd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/ingestd -config /etc/lakehouse/lakehouse.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-ingestd
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go matrixd — multi-corpus retrieve+merge with Shape B playbook
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target lakehouse-embedd.service lakehouse-vectord.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=lakehouse-embedd.service lakehouse-vectord.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/matrixd -config /etc/lakehouse/lakehouse.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-matrixd
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go observerd — witness ring + workflow runner + inbox
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
# observerd CAN call matrixd (workflow modes that hit matrix.search)
|
||||||
|
# but doesn't strictly require it — modes that fail at startup are
|
||||||
|
# logged and the daemon keeps running. So no Requires= here.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/observerd -config /etc/lakehouse/lakehouse.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
# observerd's [observerd].persist_path defaults under
|
||||||
|
# /var/lib/lakehouse/observer/ for ops.jsonl persistence.
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-observerd
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go pathwayd — Mem0-style versioned trace store
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/pathwayd -config /etc/lakehouse/lakehouse.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
# pathwayd's [pathwayd].persist_path defaults under /var/lib/lakehouse/pathway/
|
||||||
|
# in production for JSONL append-only persistence.
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-pathwayd
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go queryd — DuckDB-over-S3 SQL surface
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target lakehouse-catalogd.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=lakehouse-catalogd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
# queryd's DuckDB httpfs path needs S3 credentials; secrets_path
|
||||||
|
# in lakehouse.toml [queryd] points at /etc/lakehouse/secrets-go.toml
|
||||||
|
# which storaged also reads.
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/queryd -config /etc/lakehouse/lakehouse.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-queryd
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go storaged — S3-backed object store gateway
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
# Operator prereq: MinIO (or AWS S3) reachable at the URL in
|
||||||
|
# /etc/lakehouse/secrets-go.toml [s3.primary]. Not a systemd unit
|
||||||
|
# we control, so we just wait for network and let the bind+probe
|
||||||
|
# in storaged surface unreachable-bucket errors.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/storaged -config /etc/lakehouse/lakehouse.toml -secrets /etc/lakehouse/secrets-go.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Per ADR-006 Decision 6.2: auth token from env, not committed TOML.
|
||||||
|
# Empty AUTH_TOKEN is fine for loopback-only deploys (matches
|
||||||
|
# requireAuthOnNonLoopback gate at startup).
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
|
||||||
|
# Hardening — minimum needed for the daemon to read its config
|
||||||
|
# + write its log + open its bind port.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
# Log routing — JSON to journald, structured per slogRequest middleware.
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-storaged
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lakehouse-Go vectord — HNSW vector index registry
|
||||||
|
Documentation=https://git.agentview.dev/profit/golangLAKEHOUSE
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
# storaged is OPTIONAL for vectord — when [vectord].storaged_url is
|
||||||
|
# empty, indexes are in-memory-only and don't survive restart. When
|
||||||
|
# set, vectord persists LHV1 files via storaged. Operator's call.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lakehouse
|
||||||
|
Group=lakehouse
|
||||||
|
WorkingDirectory=/var/lib/lakehouse
|
||||||
|
ExecStart=/usr/local/bin/lakehouse/vectord -config /etc/lakehouse/lakehouse.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
EnvironmentFile=-/etc/lakehouse/auth.env
|
||||||
|
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/lakehouse /var/log/lakehouse
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=lakehouse-vectord
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=lakehouse-go.target
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Lakehouse-Go — 11-daemon docker-compose deployment.
|
||||||
|
#
|
||||||
|
# Same dependency graph as deploy/systemd/, mapped to Compose's
|
||||||
|
# `depends_on` + healthchecks. Operators MUST provide:
|
||||||
|
# - lakehouse.toml mounted at /etc/lakehouse/lakehouse.toml
|
||||||
|
# - secrets-go.toml mounted at /etc/lakehouse/secrets-go.toml
|
||||||
|
# (mode 0600 on host)
|
||||||
|
# - auth.env mounted at /etc/lakehouse/auth.env (per ADR-006 —
|
||||||
|
# required if any service binds non-loopback)
|
||||||
|
# - MinIO + Ollama reachable from the lakehouse network
|
||||||
|
#
|
||||||
|
# Bring up: docker compose up -d
|
||||||
|
# Tear down: docker compose down
|
||||||
|
# Logs: docker compose logs -f gateway
|
||||||
|
# Status: docker compose ps
|
||||||
|
|
||||||
|
x-lakehouse-base: &lakehouse-base
|
||||||
|
image: lakehouse-go:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [lakehouse]
|
||||||
|
user: "999:999" # matches the lakehouse user baked into the image
|
||||||
|
volumes:
|
||||||
|
- ./lakehouse.toml:/etc/lakehouse/lakehouse.toml:ro
|
||||||
|
- lakehouse-state:/var/lib/lakehouse
|
||||||
|
env_file:
|
||||||
|
- ./auth.env
|
||||||
|
|
||||||
|
x-healthcheck: &lakehouse-healthcheck
|
||||||
|
test: ["CMD", "curl", "-sSf", "http://127.0.0.1:$$PORT/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 2s
|
||||||
|
start_period: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
storaged:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-storaged
|
||||||
|
command: ["/usr/local/bin/lakehouse/storaged", "-config", "/etc/lakehouse/lakehouse.toml", "-secrets", "/etc/lakehouse/secrets-go.toml"]
|
||||||
|
ports: ["3211:3211"]
|
||||||
|
volumes:
|
||||||
|
- ./lakehouse.toml:/etc/lakehouse/lakehouse.toml:ro
|
||||||
|
- ./secrets-go.toml:/etc/lakehouse/secrets-go.toml:ro
|
||||||
|
- lakehouse-state:/var/lib/lakehouse
|
||||||
|
environment: [PORT=3211]
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
|
||||||
|
catalogd:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-catalogd
|
||||||
|
command: ["/usr/local/bin/lakehouse/catalogd", "-config", "/etc/lakehouse/lakehouse.toml"]
|
||||||
|
ports: ["3212:3212"]
|
||||||
|
environment: [PORT=3212]
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
depends_on:
|
||||||
|
storaged: { condition: service_healthy }
|
||||||
|
|
||||||
|
ingestd:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-ingestd
|
||||||
|
command: ["/usr/local/bin/lakehouse/ingestd", "-config", "/etc/lakehouse/lakehouse.toml"]
|
||||||
|
ports: ["3213:3213"]
|
||||||
|
environment: [PORT=3213]
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
depends_on:
|
||||||
|
storaged: { condition: service_healthy }
|
||||||
|
catalogd: { condition: service_healthy }
|
||||||
|
|
||||||
|
queryd:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-queryd
|
||||||
|
command: ["/usr/local/bin/lakehouse/queryd", "-config", "/etc/lakehouse/lakehouse.toml"]
|
||||||
|
ports: ["3214:3214"]
|
||||||
|
volumes:
|
||||||
|
- ./lakehouse.toml:/etc/lakehouse/lakehouse.toml:ro
|
||||||
|
- ./secrets-go.toml:/etc/lakehouse/secrets-go.toml:ro
|
||||||
|
- lakehouse-state:/var/lib/lakehouse
|
||||||
|
environment: [PORT=3214]
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
depends_on:
|
||||||
|
catalogd: { condition: service_healthy }
|
||||||
|
|
||||||
|
vectord:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-vectord
|
||||||
|
command: ["/usr/local/bin/lakehouse/vectord", "-config", "/etc/lakehouse/lakehouse.toml"]
|
||||||
|
ports: ["3215:3215"]
|
||||||
|
environment: [PORT=3215]
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
|
||||||
|
embedd:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-embedd
|
||||||
|
command: ["/usr/local/bin/lakehouse/embedd", "-config", "/etc/lakehouse/lakehouse.toml"]
|
||||||
|
ports: ["3216:3216"]
|
||||||
|
environment: [PORT=3216]
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
# No depends_on — Ollama is operator infra, not a compose service.
|
||||||
|
# embedd surfaces unreachable Ollama as 502 at request time.
|
||||||
|
|
||||||
|
pathwayd:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-pathwayd
|
||||||
|
command: ["/usr/local/bin/lakehouse/pathwayd", "-config", "/etc/lakehouse/lakehouse.toml"]
|
||||||
|
ports: ["3217:3217"]
|
||||||
|
environment: [PORT=3217]
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
|
||||||
|
observerd:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-observerd
|
||||||
|
command: ["/usr/local/bin/lakehouse/observerd", "-config", "/etc/lakehouse/lakehouse.toml"]
|
||||||
|
ports: ["3219:3219"]
|
||||||
|
environment: [PORT=3219]
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
|
||||||
|
matrixd:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-matrixd
|
||||||
|
command: ["/usr/local/bin/lakehouse/matrixd", "-config", "/etc/lakehouse/lakehouse.toml"]
|
||||||
|
ports: ["3218:3218"]
|
||||||
|
environment: [PORT=3218]
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
depends_on:
|
||||||
|
embedd: { condition: service_healthy }
|
||||||
|
vectord: { condition: service_healthy }
|
||||||
|
|
||||||
|
chatd:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-chatd
|
||||||
|
command: ["/usr/local/bin/lakehouse/chatd", "-config", "/etc/lakehouse/lakehouse.toml"]
|
||||||
|
ports: ["3220:3220"]
|
||||||
|
environment: [PORT=3220]
|
||||||
|
# chatd's per-provider key files — each as its own env_file so a
|
||||||
|
# missing file is just "this provider unregistered" not a startup
|
||||||
|
# failure. Same pattern as the systemd unit's EnvironmentFile=- list.
|
||||||
|
env_file:
|
||||||
|
- ./auth.env
|
||||||
|
- ./ollama_cloud.env
|
||||||
|
- ./openrouter.env
|
||||||
|
- ./opencode.env
|
||||||
|
- ./kimi.env
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
<<: *lakehouse-base
|
||||||
|
container_name: lakehouse-gateway
|
||||||
|
command: ["/usr/local/bin/lakehouse/gateway", "-config", "/etc/lakehouse/lakehouse.toml"]
|
||||||
|
ports: ["3110:3110"]
|
||||||
|
environment: [PORT=3110]
|
||||||
|
healthcheck: *lakehouse-healthcheck
|
||||||
|
# Wants= equivalent in compose: depends_on without
|
||||||
|
# condition: service_healthy, so a single upstream restart
|
||||||
|
# doesn't cascade-restart the gateway.
|
||||||
|
depends_on:
|
||||||
|
- storaged
|
||||||
|
- catalogd
|
||||||
|
- ingestd
|
||||||
|
- queryd
|
||||||
|
- vectord
|
||||||
|
- embedd
|
||||||
|
- pathwayd
|
||||||
|
- observerd
|
||||||
|
- matrixd
|
||||||
|
- chatd
|
||||||
|
|
||||||
|
networks:
|
||||||
|
lakehouse:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
lakehouse-state:
|
||||||
|
# /var/lib/lakehouse persisted across container restarts. Bind
|
||||||
|
# to a host path if backups need to live outside the volume:
|
||||||
|
# driver: local
|
||||||
|
# driver_opts:
|
||||||
|
# type: none
|
||||||
|
# o: bind
|
||||||
|
# device: /opt/lakehouse-state
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# Lakehouse: Rust vs Go architecture comparison
|
||||||
|
|
||||||
|
> **Status**: Living document · primary source for the parallel-runtime
|
||||||
|
> comparison.
|
||||||
|
> **Owner**: J. Update this when either side ships a fix that changes
|
||||||
|
> the table values, or when a new architectural axis surfaces.
|
||||||
|
> **Last meaningful refresh**: 2026-05-02 (post-Lance-gauntlet + observability parity)
|
||||||
|
|
||||||
|
This document compares the two parallel implementations of the lakehouse
|
||||||
|
substrate — Rust at `/home/profit/lakehouse/` (production today), Go at
|
||||||
|
`/home/profit/golangLAKEHOUSE/` (cutover-prep, Bun `/_go/*` slice live).
|
||||||
|
The goal of running both lines is to find where each architecture is
|
||||||
|
weak vs strong, address those gaps, and make the keep/maintain
|
||||||
|
decision based on real evidence rather than preference.
|
||||||
|
|
||||||
|
A snapshot of this document at any point in time is also captured at
|
||||||
|
`reports/cutover/architecture_comparison.md`. The version in `docs/`
|
||||||
|
is the source of truth; `reports/cutover/` is the historical record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to update this doc
|
||||||
|
|
||||||
|
Three triggers:
|
||||||
|
|
||||||
|
1. **A fix lands on either side that moves a table value.** Update the
|
||||||
|
number, append a one-line entry to the change log at the bottom,
|
||||||
|
commit alongside the fix.
|
||||||
|
2. **A new architectural axis surfaces.** Add a section. Match the
|
||||||
|
shape of existing sections (table + read paragraph).
|
||||||
|
3. **A keep/maintain decision is made.** Update the Recommendation
|
||||||
|
section + change log.
|
||||||
|
|
||||||
|
Don't:
|
||||||
|
- Delete sections without recording the reason in the change log.
|
||||||
|
- Embed unverified claims — every "Rust is X" or "Go is X" should
|
||||||
|
point to either a load-test number, a code reference (`crate/file:line`),
|
||||||
|
or an explicit "asserted, not measured" caveat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions tracker
|
||||||
|
|
||||||
|
| Date | Decision | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-05-01 | Add LRU embed cache to Rust aibridge | Closes 236× perf gap. **DONE** (commit `150cc3b` in lakehouse). |
|
||||||
|
| 2026-05-01 | Port FillValidator + EmailValidator to Go | Production safety net Go was missing. **DONE** (commit `b03521a` in golangLAKEHOUSE). |
|
||||||
|
| 2026-05-01 | Multi-tier load test against 100k corpus | 335k scenarios in 5min, 4/6 at 0% fail. Surfaced coder/hnsw v0.6.1 bug. Recover guard added. **DONE** (multitier_100k.md). |
|
||||||
|
| 2026-05-01 | **coder/hnsw v0.6.1 panic — REAL FIX landed** | Lifted source-of-truth out of coder/hnsw via `i.vectors map[string][]float32` side store + `safeGraphAdd`/`safeGraphDelete` recover wrappers + warm-path rebuild fallback. Re-run: 0 failures across 19,622 scenarios (was 96-98% on 2/6). **DONE.** Architecture invariant in STATE_OF_PLAY "DO NOT RELITIGATE". |
|
||||||
|
| 2026-05-02 | **Substrate fix verified at original failure scale** | Re-ran multitier 5min @ conc=50 (the footprint that originally surfaced the bug at 96-98% fail). Result: 132,211 scenarios at 438/sec, **6/6 classes at 0% failure**. Throughput dropped 1,115/sec → 438/sec because broken scenarios now do real HNSW Add work. Tails healthy: surge_fill_validate p99=1.53s, playbook_record_replay p99=2.32s. **Fix scales — closing the open thread.** |
|
||||||
|
| 2026-05-02 | **Drop Python sidecar from Rust aibridge — DONE** | `crates/aibridge/src/client.rs` rewrite (commit `ba928b1` in lakehouse). AiClient now talks Ollama directly: per-text `/api/embed` loop, `/api/generate` for chat + rerank-loop + admin (unload/preload), `/api/ps` + `std::process::Command nvidia-smi` for vram_snapshot. Public API unchanged — 0 callers updated. Verification: cargo test -p aibridge 32/32 PASS, live smoke `/ai/embed` returns 768-dim vector + `/v1/chat` returns "OK". Sidecar's `lab_ui.py` + `pipeline_lab.py` (~888 LOC dev-only UIs) keep running; only the hot-path embed/generate/rerank/admin routes are retired. Process count drops from "mega-binary + sidecar" to "mega-binary alone". |
|
||||||
|
| 2026-05-02 | **Rust observability parity (trace-id + session JSONL) — DONE** | Mirrors the Go-side wave (commits `d6d2fdf` + `1a3a82a` in golangLAKEHOUSE). `crates/gateway/src/v1/iterate.rs` now reads `X-Lakehouse-Trace-Id` header, forwards to `/v1/chat` + `/v1/validate` hops, emits per-attempt Langfuse spans (`iterate.attempt[N]`), and writes one `SessionRecord` JSONL row per session via the new `crates/gateway/src/v1/session_log.rs` writer. New `crates/gateway/src/bin/parity_session_log` helper enables the cross-runtime parity probe at `scripts/cutover/parity/session_log_parity.sh` — **4/4 fixtures byte-equal** (including unicode prompts) after normalizing the 2 fields that legitimately differ (timestamp, daemon name). Both runtimes can write to the same path and DuckDB queries see them as one stream. 90/90 Rust unit tests PASS, 33 Go packages PASS. |
|
||||||
|
| 2026-05-02 | **Cross-runtime parity gap closure — DONE** | Two follow-ups from the deploy + reality wave: (1) Go validatord didn't honor `X-Lakehouse-Trace-Id` when Langfuse middleware was a passthrough (no env config) — fixed by reading the header DIRECTLY in the iterate handler before the ctx fallback (Go commit `6847bbc`). (2) Rust `IterateResponse`/`IterateFailure` didn't echo `trace_id` back to callers — added the field with skip-serializing-if-none for backward compat (Rust commit `98b6647`). Plus enabled `[gateway].session_log_path` on live Rust pointing at the SAME path Go writes to, so DuckDB queries see one unified longitudinal log. **Verified end-to-end**: identical /v1/iterate to both runtimes lands in one sessions.jsonl tagged `daemon=gateway` (Rust) vs `daemon=validatord` (Go); response bodies both echo the forwarded trace_id. 24/24 parity assertions (validator+extract_json+session_log+materializer) hold post-restart. |
|
||||||
|
| 2026-05-02 | **Port Rust materializer to Go (transforms.ts) — DONE** | `internal/materializer` + `cmd/materializer` + `materializer_smoke.sh`. Ports `transforms.ts` (12 transforms) + `build_evidence_index.ts`. Idempotency, day-partition, receipt. 14 tests green; on-wire JSON matches TS so both runtimes interoperate. |
|
||||||
|
| 2026-05-02 | **Port Rust replay tool to Go — DONE** | `internal/replay` + `cmd/replay` + `replay_smoke.sh`. Ports `replay.ts` retrieve → bundle → /v1/chat → validate → log. Closes audit-FULL phase 7 live invocation on Go side. 14 tests green; same `data/_kb/replay_runs.jsonl` shape (schema=replay_run.v1) as TS. |
|
||||||
|
| 2026-05-02 | **`/v1/validate` + `/v1/iterate` HTTP surface — DONE** | `cmd/validatord` (port 3221) hosts both endpoints. `internal/validator` gains `PlaybookValidator` (3rd kind), JSONL roster loader, and the `Iterate` orchestrator + `ExtractJSON` helper. Gateway proxies `/v1/validate` + `/v1/iterate` to validatord. Closes the last "Go-primary" backlog item (architecture_comparison.md item #7). 30+ tests + `validatord_smoke.sh` 5/5 PASS. |
|
||||||
|
| 2026-05-02 | **Cross-runtime validator parity probe — surfaced wire-format gap** | New `scripts/cutover/parity/validator_parity.sh` runs 6 identical /v1/validate cases against Rust :3100 AND Go :4110, compares status + body. Result: **6/6 status codes match (logic-level equivalence holds), 5/6 body shapes diverge.** Rust returns serde-tagged enum `{"Schema":{"field":"x","reason":"y"}}`; Go returns flat struct `{"Kind":"schema","Field":"x","Reason":"y"}`. Any caller parsing the error envelope would break in cutover. **Open**: pick a target shape (Go matching Rust is the cutover-friendly direction) and align via custom `MarshalJSON` on `ValidationError`. |
|
||||||
|
| 2026-05-02 | **Materializer parity probe — caught + fixed real bug** | New `scripts/cutover/parity/materializer_parity.sh` runs Bun + Go materializer on identical synthetic root, diffs output JSONL. Result on first run: **0/2 match** — Go's `Provenance.LineOffset` had `json:",omitempty"` and stripped the field on first-row records (line_offset=0 is semantically meaningful, not absent). 1-line fix (drop `omitempty` + comment explaining why). Re-run: **2/2 match**. Real cross-runtime gap surfaced + closed in same wave. |
|
||||||
|
| 2026-05-02 | **extract_json parity probe — 12/12 match across edge cases** | New `scripts/cutover/parity/extract_json_parity.sh` runs identical model-output strings through Rust `gateway::v1::iterate::extract_json` AND Go `validator.ExtractJSON`. 12 fixtures: fenced/unfenced blocks, nested objects, unicode, escaped quotes, top-level array, malformed JSON. Substrate gate: `cargo test -p gateway extract_json` PASS before probe. Result: **12/12 match.** Algorithms genuinely equivalent. Rust side gained `pub` on `extract_json` + new `bin/parity_extract_json` (~30 LOC). |
|
||||||
|
| 2026-05-02 | **Validator wire-format alignment — DONE** | Custom `MarshalJSON`/`UnmarshalJSON` on Go's `validator.ValidationError` emits the Rust serde-tagged-enum shape `{"Schema":{"field":"x","reason":"y"}}`. UnmarshalJSON also accepts the legacy flat shape (migration safety) and rejects unknown variants (drift guard for future Rust enum additions). 4 new pinning tests in `types_test.go`. Re-run validator parity probe: **6/6 match** (was 1/6). |
|
||||||
|
| 2026-05-02 | **Lance backend gauntlet (4-pack + root-cause fix) — DONE** | Lance crate had zero tests + no smoke when audited this morning. Shipped: (a) `sanitize_lance_err` over all 5 routes (search/doc/index/append/migrate) — missing-index now 404 not 500, no `/home/` or `/root/.cargo/` paths leaked; (b) 7 unit tests in `crates/vectord-lance` with synth Parquet helper; (c) 9-probe `scripts/lance_smoke.sh` against live `:3100`; (d) 10M re-bench (`reports/lance_10m_rebench_2026-05-02.md`) — search warm ~20ms, search cold ~46ms median. Bench surfaced doc-fetch p50 ~100ms (300x slower than ADR-019 100K projection); root-caused to lance-bench bypassing IndexMeta → warming auto-build never fired → no `doc_id` btree. **Fix shipped (commit `5d30b3d`)**: `lance_migrate` HTTP handler now auto-builds the btree inline (1.2s on 10M, +269MB), drops doc-fetch to ~5ms (20x). Live verified 9/9 smoke + post-restart doc-fetch 4-15ms. |
|
||||||
|
| 2026-05-03 | **Subject manifests + per-subject HMAC audit log — DONE on Rust + Go** | Local-first compliance substrate per `lakehouse/docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md` Steps 1-8. Rust shipped: `SubjectManifest` type + Registry CRUD (`crates/catalogd/src/registry.rs`), `SubjectAuditWriter` with HMAC-SHA256 chain + per-subject Mutex serialization + canonical-JSON via BTreeMap (`subject_audit.rs`), backfill ETL (`bin/backfill_subjects`), gateway tool dispatch + validator decorator wiring, legal-tier `/audit/subject/{id}` endpoint with constant-time-eq token + tampering detection, daily `bin/retention_sweep` (BIPA-aware, idempotent, no auto-mutation). Go shipped: identical `internal/catalogd/subject.go` reader + `VerifyChain` over RAW LINE BYTES (avoids time-precision drift), 11 unit tests. **6th cross-runtime parity probe**: `scripts/cutover/parity/subject_audit_parity.sh` — 6/6 byte-identical assertions across known-answer fixture + 5 real production audit logs. Surfaced + closed three drift classes in authoring loop: (1) Go `omitempty` stripping `trace_id:""`; (2) `time.RFC3339Nano` truncating trailing-zero nanoseconds where chrono AutoSi keeps 9 digits; (3) Go `json.Marshal` HTML-escaping `<>&` where serde keeps literal — fixed via `marshalNoEscapeHTML` + raw-bytes canonicalization. Two cross-lineage scrums caught real bugs each round (chain corruption race, schema-evolution HMAC drift, hardcoded "success" classifier, token min length, chain_root from windowed slice, tampering detection, HTML escape divergence). |
|
||||||
|
| _open_ | Decide Lance vs Parquet+HNSW for primary | Lance verified production-ready at 10M (this morning's gauntlet). HNSW at 10M doesn't fit RAM (~60GB for vectors+graph), so the comparison is between Lance and Parquet+HNSW-with-spilling. Decide once we have a 10M ingest scenario where the Parquet path is bottlenecked. |
|
||||||
|
| _open_ | Pick Go primary vs Rust primary | Both viable. Go has perf edge after today; Rust has production deploy + producer-side completeness. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code volume
|
||||||
|
|
||||||
|
| | Lines | Last verified |
|
||||||
|
|---|---:|---|
|
||||||
|
| Rust `crates/` (15 crates) | 35,447 | 2026-05-01 |
|
||||||
|
| Rust `sidecar/` (Python) | 1,237 | 2026-05-01 |
|
||||||
|
| Go `internal/` (20 packages) | 11,896 (+ validator 1190) | 2026-05-01 |
|
||||||
|
| Go `cmd/` (14 binaries) | 3,232 | 2026-05-01 |
|
||||||
|
| **Go total** | **~16,300** | 2026-05-01 |
|
||||||
|
|
||||||
|
Go is ~46% the size of Rust on like-for-like surface (post-validator-port).
|
||||||
|
The gap is largely `vectord` (Rust 11,005 lines vs Go 804) — Rust's
|
||||||
|
vectord implements HNSW + Lance-format storage + benchmarking; Go's
|
||||||
|
wraps `coder/hnsw` and stops there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Process model
|
||||||
|
|
||||||
|
| | Rust | Go |
|
||||||
|
|---|---|---|
|
||||||
|
| Binaries running | **1** mega-process (gateway PID 1241, 14.9G RSS, 374% CPU under load) | **11** dedicated daemons (~100-300MB RSS each) |
|
||||||
|
| Inter-component comms | In-process axum.nest (no network) | HTTP between daemons |
|
||||||
|
| Crash blast radius | Whole system if any subsystem panics | One daemon dies, rest survive |
|
||||||
|
| Horizontal scale | One unit only — can't scale individual components | Each daemon scales independently |
|
||||||
|
| Deploy unit | Single binary | 11 systemd units |
|
||||||
|
|
||||||
|
**Reading**: Rust's mega-binary is simpler ops at small scale (one
|
||||||
|
thing to start, one log to tail). Go's daemons are simpler ops at
|
||||||
|
production scale (kill the misbehaving one, restart it, others stay
|
||||||
|
up). Go also lets you tune per-daemon resource limits via systemd.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python dependency (the load-bearing axis)
|
||||||
|
|
||||||
|
This was the architectural difference that drove the original perf gap.
|
||||||
|
|
||||||
|
```
|
||||||
|
Pre-2026-05-02:
|
||||||
|
Rust embed: gateway → HTTP → Python sidecar :3200 → HTTP → Ollama :11434
|
||||||
|
Go embed: gateway → HTTP → Go embedd :4216 → HTTP → Ollama :11434
|
||||||
|
|
||||||
|
Post-2026-05-02 (commit ba928b1):
|
||||||
|
Rust embed: gateway → HTTP → Ollama :11434 (sidecar dropped)
|
||||||
|
Go embed: gateway → HTTP → Go embedd :4216 → HTTP → Ollama :11434
|
||||||
|
```
|
||||||
|
|
||||||
|
The hot-path Python sidecar (~120 LOC across embed.py / generate.py
|
||||||
|
/ rerank.py / admin.py) was pure pass-through to Ollama and added no
|
||||||
|
logic — just translation. AiClient was rewritten 2026-05-02 to call
|
||||||
|
Ollama directly. `lab_ui.py` + `pipeline_lab.py` (~888 LOC of
|
||||||
|
Streamlit-shape dev UIs) remain as ad-hoc tooling, not on the
|
||||||
|
runtime hot path.
|
||||||
|
|
||||||
|
### Performance impact (load-tested 2026-05-01, 6 rotating bodies, 10 concurrency, 30s)
|
||||||
|
|
||||||
|
| Path | Pre-cache | Post-cache (`150cc3b`) | Δ |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| **Rust /ai/embed** (via gateway) | 128 RPS · p50 78ms · p99 124ms | **30,279 RPS · p50 129µs · p99 5ms** | +236× RPS |
|
||||||
|
| **Go /v1/embed** (via gateway → embedd) | 8,119 RPS · p50 0.79ms · p99 3ms | _unchanged_ | (already cached) |
|
||||||
|
|
||||||
|
Rust now beats Go ~3.7× on cache-warm workloads. The cache being
|
||||||
|
in-process inside Rust's gateway (no HTTP hop to a separate daemon)
|
||||||
|
gives it the edge once both sides have caching.
|
||||||
|
|
||||||
|
### What the cache fix did NOT do
|
||||||
|
|
||||||
|
The Python sidecar is still in the Rust path on cache misses. Cold
|
||||||
|
queries pay the full Python+Ollama tax. Dropping the sidecar
|
||||||
|
(rewriting aibridge to call Ollama directly) is the next universal-win
|
||||||
|
item — open in the Decisions tracker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vector storage
|
||||||
|
|
||||||
|
| | Rust | Go |
|
||||||
|
|---|---|---|
|
||||||
|
| HNSW lib | `hnsw_rs` (mature) | `coder/hnsw` (newer, smaller) |
|
||||||
|
| Code size | 11,005 lines (`vectord` + `vectord-lance`) | 804 lines |
|
||||||
|
| Lance-format storage | Yes (`vectord-lance` crate) | No |
|
||||||
|
| Persistence | LanceDB or in-memory | MinIO + JSON envelope (v2 envelope as of `eb0dfdf`) |
|
||||||
|
| Distance functions | cosine, euclidean, dot product | cosine, euclidean |
|
||||||
|
|
||||||
|
**Reading**: Rust has the deeper substrate. Lance-format gives columnar
|
||||||
|
persistence + zero-copy reads + Apache Arrow integration. For
|
||||||
|
staffing-domain corpus sizes (5K-500K vectors) both work fine; for
|
||||||
|
multi-million-row indexes Rust would have a real edge. **Defer the Go
|
||||||
|
Lance port until corpus growth demands it.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Distillation pipeline (porting status)
|
||||||
|
|
||||||
|
| Phase | Rust source | Go port |
|
||||||
|
|---|---|---|
|
||||||
|
| Materializer (transforms.ts) | TS, full | ❌ NOT YET PORTED |
|
||||||
|
| Scorer | TS + Go | ✅ Ported |
|
||||||
|
| Score categories + firewall | Pinned | ✅ Ported (`SftNever`) |
|
||||||
|
| SFT export (synthesis) | TS, full (8 source classes) | ✅ Fully ported, 4-decimal byte-equal |
|
||||||
|
| RAG export | TS | ❌ NOT YET PORTED |
|
||||||
|
| Preference export | TS | ❌ NOT YET PORTED |
|
||||||
|
| Audit-baselines | TS | ✅ Fully ported, byte-equal verified |
|
||||||
|
| Audit-FULL phase 0/3/4 | TS | ✅ Ported |
|
||||||
|
| Audit-FULL phase 1 (schema) | bun test | ✅ Via `go test` exec |
|
||||||
|
| Audit-FULL phase 2 (materializer) | TS | ✅ Observer mode (read-only) |
|
||||||
|
| Audit-FULL phase 5 (run summaries) | TS | ✅ Observer mode (read-only) |
|
||||||
|
| Audit-FULL phase 6 (acceptance) | TS fixture harness | ❌ Skipped (TS-only deps) |
|
||||||
|
| Audit-FULL phase 7 (replay) | TS | ✅ Observer mode (read-only) |
|
||||||
|
| Replay tool | TS | ❌ NOT YET PORTED |
|
||||||
|
| Quarantine writer | TS | ❌ NOT YET PORTED |
|
||||||
|
|
||||||
|
**Reading**: Go has the substrate for everything observable (read
|
||||||
|
paths) and SFT export end-to-end. The producer side (materializer,
|
||||||
|
replay) is still Rust-only. To run the full pipeline from Go alone,
|
||||||
|
the materializer + replay need porting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production validators
|
||||||
|
|
||||||
|
| | Rust | Go |
|
||||||
|
|---|---|---|
|
||||||
|
| FillValidator | `crates/validator/src/staffing/fill.rs` (12 unit tests) | ✅ **Ported 2026-05-01** (`internal/validator/fill.go` + 13 tests) |
|
||||||
|
| EmailValidator | `crates/validator/src/staffing/email.rs` (12 tests) | ✅ **Ported 2026-05-01** (`internal/validator/email.go` + 11 tests) |
|
||||||
|
| `/v1/validate` endpoint | Yes | ❌ NOT YET PORTED (validator network surface) |
|
||||||
|
| `/v1/iterate` endpoint | Yes (gen→validate→correct→retry loop) | ❌ NOT YET PORTED |
|
||||||
|
| Production validators load `workers_500k.parquet` at startup | Yes (75MB resident) | N/A — Go uses WorkerLookup interface; in-memory or adapter |
|
||||||
|
|
||||||
|
**Reading**: With today's port, Go has the load-bearing validators.
|
||||||
|
The network surface (`/v1/validate`, `/v1/iterate`) is the next
|
||||||
|
piece — the in-memory validators work in-process; turning them into
|
||||||
|
HTTP endpoints adds the production-shape access pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Substrate features unique to each side
|
||||||
|
|
||||||
|
### Go has, Rust doesn't
|
||||||
|
|
||||||
|
- **chatd 5-provider dispatcher** (kimi / opencode / openrouter / ollama_cloud / ollama).
|
||||||
|
- **Cross-role gate** in matrix retrieve (real_001 fix). Verified by reality tests real_001..005.
|
||||||
|
- **Multi-corpus matrix indexer** (Spec §3.4 component 2).
|
||||||
|
- **Pathway memory** (Mem0-style versioned traces).
|
||||||
|
- **Observer fail-safe semantics** (ADR-005 Decision 5.1).
|
||||||
|
- **In-process embed cache** (CachedProvider + LRU). _Note: Rust got this 2026-05-01 too._
|
||||||
|
- **LLM-based role extractor** (regex + qwen2.5 fallback).
|
||||||
|
- **Persistent stack 3-layer isolation** (`scripts/cutover/start_go_stack.sh`).
|
||||||
|
- **Cutover slice** (Bun `/_go/*` route, opt-in via systemd drop-in).
|
||||||
|
- **Production load test** (`scripts/cutover/loadgen/`) with Bun-frontend + direct comparison.
|
||||||
|
|
||||||
|
### Rust has, Go doesn't
|
||||||
|
|
||||||
|
- **Lance-format vector storage** (vectord-lance crate, 605 lines).
|
||||||
|
- **`truth` crate** (970 lines). Cross-source claim reconciliation.
|
||||||
|
- **`journald` crate** (455 lines). Structured event journal.
|
||||||
|
- **`/v1/validate` + `/v1/iterate` endpoints** (network surface).
|
||||||
|
- **`ui` crate (Dioxus, 1,509 lines)**. Native desktop/web UI.
|
||||||
|
- **Materializer + replay tools** (the "produce evidence" side).
|
||||||
|
- **Acceptance harness** (22 invariants over fixtures, TS).
|
||||||
|
- **Production deployment** (devop.live/lakehouse/* serves through Rust today).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strengths and weaknesses
|
||||||
|
|
||||||
|
### Rust strengths
|
||||||
|
|
||||||
|
- Mature, in production, serving real demo traffic.
|
||||||
|
- Single deploy unit; one binary, one systemd service, one log.
|
||||||
|
- Type system + memory safety; fewer runtime bugs in hot paths.
|
||||||
|
- Mature library ecosystem (axum, tokio, polars, arrow, hnsw_rs, lance).
|
||||||
|
- Native distillation pipeline; Go is the porter.
|
||||||
|
- Production validators (now also in Go but Rust authored them).
|
||||||
|
- Lance vector storage scales beyond 5M rows.
|
||||||
|
- **In-process embed cache (post-`150cc3b`) makes Rust the fastest path on warm workloads.**
|
||||||
|
|
||||||
|
### Rust weaknesses
|
||||||
|
|
||||||
|
- **Python sidecar dependency** — every cache-miss AI call goes through Python. Adds 1 runtime + 1 process to ops. ~200 LOC to fix.
|
||||||
|
- **Mega-binary blast radius** — gateway at 14.9G RSS means any panic kills the whole production system.
|
||||||
|
- **Tail latency cliff under uncached load** — single async runtime serializes I/O completions.
|
||||||
|
- **Compile times** — slow iteration vs Go's per-package builds.
|
||||||
|
- **Coupling** — adding a feature touches gateway/v1/ and ripples across crates.
|
||||||
|
|
||||||
|
### Go strengths
|
||||||
|
|
||||||
|
- **Process isolation** — daemons crash independently; ops can `systemctl restart vectord` without touching gateway.
|
||||||
|
- **Per-daemon scale** — embed cache lives in embedd; vectord shards independently. Hot daemons scale horizontally.
|
||||||
|
- **No Python dependency** — every daemon talks to peers in HTTP/JSON. Native Go down to Ollama.
|
||||||
|
- **In-process embed cache** at the daemon level (was the perf lever pre-Rust-cache).
|
||||||
|
- **Smaller, denser code** — 16,300 lines vs Rust's 35,447 + 1,237 sidecar (~46% the size).
|
||||||
|
- **Faster iteration** — `go build` of all 14 binaries is ~3-5s; Rust full rebuild is minutes.
|
||||||
|
- **Cross-runtime artifact compatibility verified** — audit_baselines.jsonl, scored-runs JSONL, sft_export.jsonl all round-trip byte-equal.
|
||||||
|
|
||||||
|
### Go weaknesses
|
||||||
|
|
||||||
|
- **Distillation pipeline incomplete** — materializer + replay + RAG export + preference export still Rust-only.
|
||||||
|
- **Validator network surface missing** — in-memory validators work, but `/v1/validate` HTTP endpoint not yet ported. Operators can't call validators over the wire from Go.
|
||||||
|
- **Vector storage HNSW-only** — no Lance equivalent. Fine for current scale.
|
||||||
|
- **Less production-tested** — cutover slice live but no real coordinator traffic yet.
|
||||||
|
- **HTTP between daemons** — every cross-daemon call is a network round-trip. Latency fine on localhost (microseconds) but tail-latency contributes more than Rust's in-process composition.
|
||||||
|
- **`coder/hnsw` is newer** than Rust's `hnsw_rs`. Less battle-tested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-cutting abstracts to address
|
||||||
|
|
||||||
|
The list below is a working backlog. Move items to "Decisions tracker"
|
||||||
|
(at top) when actioned with a commit reference.
|
||||||
|
|
||||||
|
### Universal wins (apply regardless of primary line)
|
||||||
|
|
||||||
|
1. ✅ **Embed cache in Rust aibridge** — DONE 2026-05-01 (`150cc3b`).
|
||||||
|
2. ✅ **FillValidator + EmailValidator in Go** — DONE 2026-05-01 (`b03521a`).
|
||||||
|
3. ✅ **Drop Python sidecar from Rust** — DONE 2026-05-02 (`ba928b1`).
|
||||||
|
4. **Cross-runtime contract tests** — Pin shared JSONL schemas (audit_baselines, scored_run, sft_sample) as canonical specs in `auditor/schemas/` with Go-side validators consuming the same definitions.
|
||||||
|
|
||||||
|
### If keeping Go primary
|
||||||
|
|
||||||
|
5. ✅ **Port materializer** — DONE 2026-05-02 (`cmd/materializer`).
|
||||||
|
6. ✅ **Port replay tool** — DONE 2026-05-02 (`cmd/replay`).
|
||||||
|
7. ✅ **Port `/v1/validate` + `/v1/iterate` HTTP surface** — DONE 2026-05-02 (`cmd/validatord`).
|
||||||
|
8. **Skip Lance** until corpus growth demands it (>5M rows).
|
||||||
|
9. **Keep chatd, observer fail-safe, role gate, multi-corpus matrix** — real Go wins worth preserving.
|
||||||
|
|
||||||
|
### If keeping Rust primary
|
||||||
|
|
||||||
|
10. **Port chatd's 5-provider dispatcher to Rust** — unified cloud LLM access.
|
||||||
|
11. **Port the cross-role gate to Rust matrix retrieve** — production safety on the matrix layer (verified by Go reality tests real_001..005).
|
||||||
|
12. **Consider process splitting** — even partial decomposition (split out vectord into its own process) would help with the mega-binary blast radius.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation (working hypothesis)
|
||||||
|
|
||||||
|
**Go for the primary line, Rust for production-bridge maintenance.**
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
1. **Operations** — process isolation is genuinely simpler at production scale than a 14.9G mega-binary.
|
||||||
|
2. **Code volume** — Go does the same job in ~46% the lines.
|
||||||
|
3. **Cross-runtime parity verified** — every artifact round-trips byte-equal between runtimes.
|
||||||
|
4. **The 4 missing pieces are bounded** — materializer + replay + validators-network + RAG/preference exports are concrete porting targets, not research questions.
|
||||||
|
5. **Performance is no longer a deciding factor** post-`150cc3b` — Rust is faster on warm cache, but both are well above staffing-domain demand levels (<1 RPS typical).
|
||||||
|
|
||||||
|
But **don't abandon Rust**:
|
||||||
|
1. devop.live/lakehouse/ runs through Rust today; cutover is multi-week.
|
||||||
|
2. Several Go improvements would be downstream of Rust patterns. Keeping Rust live means anything new there is a porting opportunity for Go.
|
||||||
|
3. The Python sidecar drop + cross-role gate port are valuable Rust improvements regardless of which line is primary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Change log
|
||||||
|
|
||||||
|
Append entries here when this doc gets updated. One-line entries; link to commits.
|
||||||
|
|
||||||
|
- 2026-05-01 — Initial draft (`b3ad148` golangLAKEHOUSE).
|
||||||
|
- 2026-05-01 — Recorded Rust embed cache shipping (`150cc3b` lakehouse), updated Python-dependency section + table.
|
||||||
|
- 2026-05-01 — Recorded Go validator port shipping (`b03521a` golangLAKEHOUSE), updated production-validators section.
|
||||||
|
- 2026-05-01 — Reframed as living document in `docs/`, added Decisions tracker + Update guidance + Change log sections.
|
||||||
|
- 2026-05-01 — Multi-tier 100k load test ran (335k scenarios @ 1,115/sec, 4/6 at 0% fail), surfaced coder/hnsw v0.6.1 nil-deref on small playbook_memory index. Recover guard added; real fix open.
|
||||||
|
- 2026-05-01 (later) — coder/hnsw v0.6.1 panic real fix landed: vectord lifts source-of-truth out of coder/hnsw via `i.vectors` side store + recover wrappers + rebuild fallback. Re-run multitier 60s/conc=50: 0 failures across 19,622 scenarios. STATE_OF_PLAY invariant added to "DO NOT RELITIGATE".
|
||||||
|
- 2026-05-02 — Substrate fix verified at original failure-surfacing scale. Multitier 5min @ conc=50: 132,211 scenarios at 438/sec, 6/6 classes at 0% failure (was 4/6 pre-fix). Throughput drop (1,115 → 438/sec) is the honest cost of the formerly-broken scenarios doing real HNSW Add work. STATE_OF_PLAY refreshed to 2026-05-02.
|
||||||
|
- 2026-05-02 — Materializer + replay tool ported from Rust legacy to Go (`internal/materializer` + `internal/replay`, both with CLI + smoke + tests). Both runtimes now produce the same `data/evidence/YYYY/MM/DD/*.jsonl` and `data/_kb/replay_runs.jsonl` shapes; Go side no longer needs Bun for these phases.
|
||||||
|
- 2026-05-02 — `/v1/validate` + `/v1/iterate` HTTP surface ported as `cmd/validatord` on `:3221`. Closes the last "If keeping Go primary" backlog item — Go now owns the entire validator path end-to-end (no Rust dep for staffing safety net). 5/5 smoke probes via gateway :3110.
|
||||||
|
- 2026-05-02 — Python sidecar dropped from Rust hot path (`ba928b1`). AiClient now talks Ollama directly. Process count: mega-binary + sidecar → mega-binary alone. Public API unchanged, 0 callers updated. Cargo tests 32/32 PASS, live `/ai/embed` + `/v1/chat` smokes green on test gateway. Lab UI Python remains as ad-hoc dev tooling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- **`reports/cutover/architecture_comparison.md`** — historical snapshot (matched this doc as of the date stamp at top).
|
||||||
|
- **`docs/SPEC.md`** — Go-side architectural spec.
|
||||||
|
- **`docs/DECISIONS.md`** — Go-side ADRs.
|
||||||
|
- **`/home/profit/lakehouse/docs/DECISIONS.md`** — Rust-side ADRs.
|
||||||
|
- **`/home/profit/lakehouse/docs/go-rewrite/`** — Rust-side notes on the rewrite.
|
||||||
|
- **`reports/cutover/SUMMARY.md`** — running log of cross-runtime parity probes.
|
||||||
|
- **`reports/cutover/g5_load_test.md`** — load-test methodology + numbers.
|
||||||
+535
-3
@@ -121,6 +121,538 @@ historical record.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
(Future ADRs from ADR-002 onward will be added as the Go
|
## ADR-002: storaged per-prefix PUT cap (vectord _vectors/ → 4 GiB)
|
||||||
implementation accrues design decisions — e.g. HNSW parameter
|
**Date:** 2026-04-29
|
||||||
choices, pathway-memory hash function, auditor model rotation, etc.)
|
**Decided by:** J
|
||||||
|
**Status:** Implemented (commit `423a381`)
|
||||||
|
|
||||||
|
`storaged` enforces a 256 MiB per-PUT body cap as DoS protection
|
||||||
|
(`MaxBytesReader` + Content-Length check). Keys under `_vectors/`
|
||||||
|
(vectord LHV1 persistence) get a raised cap of 4 GiB; everything
|
||||||
|
else stays at 256 MiB.
|
||||||
|
|
||||||
|
**Rationale:** the 500K staffing test surfaced that single-file LHV1
|
||||||
|
above ~150K vectors at d=768 hits the 256 MiB cap. `manager.Uploader`
|
||||||
|
already streams on the outbound side, so the cap is a safety gate
|
||||||
|
not a memory bottleneck — raising it for the vector path doesn't
|
||||||
|
introduce new memory pressure. Per-prefix preserves the safety
|
||||||
|
gate for routine traffic while opening the documented production
|
||||||
|
path. Splitting LHV1 across multiple keys was rejected because G1P
|
||||||
|
specifically shipped the single-Put framed format to eliminate
|
||||||
|
torn-write — multi-key would re-introduce that failure mode.
|
||||||
|
|
||||||
|
**Follow-up:** if production workloads exceed 4 GiB single-file
|
||||||
|
LHV1, refactor to operator-driven config (env/TOML) rather than
|
||||||
|
bumping the constant. The function-level `maxPutBytesFor(key)` in
|
||||||
|
`cmd/storaged/main.go` keeps that drop-in clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-003: Inter-service auth posture — Bearer token + IP allowlist
|
||||||
|
**Date:** 2026-04-29
|
||||||
|
**Decided by:** J + Claude
|
||||||
|
**Status:** Decided — wiring deferred to Sprint 1
|
||||||
|
|
||||||
|
**Decision:** When inter-service auth is needed (the moment any
|
||||||
|
binary binds non-loopback or the deployment crosses a trust
|
||||||
|
boundary), the auth model is **a Bearer token loaded from
|
||||||
|
`secrets-go.toml` plus a configurable IP allowlist**. Both layers
|
||||||
|
required: the token authenticates the caller; the allowlist
|
||||||
|
narrows the network surface.
|
||||||
|
|
||||||
|
**Status today (G0):** zero auth middleware. Every binary binds
|
||||||
|
`127.0.0.1` by default; commit `6af0520` (R-001 partial fix) refuses
|
||||||
|
non-loopback bind unless the per-service `LH_<SVC>_ALLOW_NONLOOPBACK=1`
|
||||||
|
env override is set. The override-and-no-auth combination is the
|
||||||
|
worst case — this ADR locks in what we'll require before any
|
||||||
|
production override fires.
|
||||||
|
|
||||||
|
### What gets implemented when auth lands
|
||||||
|
|
||||||
|
1. **`secrets-go.toml` adds a `[auth]` section:**
|
||||||
|
```toml
|
||||||
|
[auth]
|
||||||
|
token = "..." # 32+ random bytes, hex-encoded
|
||||||
|
allowed_ips = ["10.0.0.0/8", "127.0.0.1/32"] # CIDR list
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`internal/shared/auth.go`** ships a single chi middleware:
|
||||||
|
```go
|
||||||
|
func RequireAuth(cfg AuthConfig) func(http.Handler) http.Handler
|
||||||
|
```
|
||||||
|
- Empty `cfg.Token` → middleware is a no-op (G0 dev mode).
|
||||||
|
- Non-empty token → reject 401 unless request has
|
||||||
|
`Authorization: Bearer <token>` matching constant-time.
|
||||||
|
- Non-empty `allowed_ips` → reject 403 unless `r.RemoteAddr` (or
|
||||||
|
`X-Forwarded-For` first hop, configurable) is in CIDR set.
|
||||||
|
- `/health` exempt — load balancers + monitors need it open.
|
||||||
|
|
||||||
|
3. **Every `cmd/<svc>/main.go` adds one line:**
|
||||||
|
```go
|
||||||
|
r.Use(shared.RequireAuth(cfg.Auth))
|
||||||
|
```
|
||||||
|
Mounted before `register(r)` so it covers every route the binary
|
||||||
|
exposes after `/health`.
|
||||||
|
|
||||||
|
4. **`shared.Run` startup gate:** if bind is non-loopback AND
|
||||||
|
`cfg.Auth.Token == ""`, refuse to start. The implicit
|
||||||
|
"localhost is the auth layer" guarantee becomes explicit when
|
||||||
|
crossing the loopback boundary.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
| Option | Why rejected |
|
||||||
|
|---|---|
|
||||||
|
| **mTLS** | Strongest but heaviest — every binary needs cert provisioning, rotation tooling, and cert-aware client wiring. Overkill for inter-service traffic that already passes through a single gateway. Reconsider when Lakehouse-Go runs across machines. |
|
||||||
|
| **JWT with short TTL** | Buys nothing over Bearer here — there's no third-party identity provider, no claim hierarchy worth modelling. Pure token has the same security properties at half the wire complexity. |
|
||||||
|
| **No auth, IP-allowlist only** | One stolen IP allowlist entry → full access. Token + IP is defense in depth; either alone is too weak. |
|
||||||
|
| **OAuth2 via external IdP** | Rejected for G0–G3 timeline. No external IdP commitment. Revisit if Lakehouse-Go ever serves end-user requests directly (today everything fronts through the staffing co-pilot which has its own session model). |
|
||||||
|
|
||||||
|
### Constant-time comparison + token hygiene
|
||||||
|
|
||||||
|
Token comparison must use `crypto/subtle.ConstantTimeCompare` —
|
||||||
|
naive `==` is vulnerable to timing attacks against an attacker who
|
||||||
|
can issue many requests and measure round-trip. Token rotation is
|
||||||
|
operator-driven via `secrets-go.toml` edit + restart; G0 doesn't
|
||||||
|
need rotate-without-restart.
|
||||||
|
|
||||||
|
### What this ADR does NOT do
|
||||||
|
|
||||||
|
- **Does not implement the middleware.** Code lands in Sprint 1.
|
||||||
|
- **Does not require token in G0 dev.** Empty token → no-op. Smokes
|
||||||
|
+ proof harness keep working without setting tokens.
|
||||||
|
- **Does not address gateway → end-user auth.** Gateway terminates
|
||||||
|
inter-service auth at its inbound; if end-users hit gateway from
|
||||||
|
a browser, that's a different ADR (likely cookie/session, fronted
|
||||||
|
by a reverse proxy that handles user auth).
|
||||||
|
|
||||||
|
### How this closes audit findings
|
||||||
|
|
||||||
|
- **R-001 (queryd /sql RCE-equivalent off-loopback):** the bind
|
||||||
|
gate prevents accidental exposure today; this ADR specifies the
|
||||||
|
guardrail when intentional exposure is needed.
|
||||||
|
- **R-007 (zero auth middleware):** answered by the design above;
|
||||||
|
R-007 stays open until the middleware is implemented but is no
|
||||||
|
longer "design TBD."
|
||||||
|
- **R-010 (no CORS posture):** orthogonal to inter-service auth,
|
||||||
|
but the `RequireAuth` middleware sits at the right layer to add
|
||||||
|
CORS handling later (browsers don't reach inter-service routes
|
||||||
|
in the current design, so CORS is also Sprint 1+ when end-user
|
||||||
|
requests start landing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-004: Pathway memory data model — Mem0-style versioned traces
|
||||||
|
**Date:** 2026-04-29
|
||||||
|
**Decided by:** J + Claude
|
||||||
|
**Status:** Decided — substrate landing in `internal/pathway/`
|
||||||
|
|
||||||
|
**Decision:** Pathway memory is an append-only event log of opaque
|
||||||
|
traces with Mem0-style semantics: Add / Update / Revise / Retire /
|
||||||
|
History / Search. Each trace has a UID; revisions chain backward
|
||||||
|
via `predecessor_uid` so the full history is reconstructible.
|
||||||
|
Persistence is JSONL append-only with full-replay on load;
|
||||||
|
corruption recovery skips bad lines without halting startup.
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
|
||||||
|
| Op | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `Add(content, tags...)` | New UID, stored fresh, replay_count=1. |
|
||||||
|
| `AddIdempotent(uid, content, tags...)` | If UID exists → replay_count++. Else → Add with that UID. |
|
||||||
|
| `Update(uid, content)` | In-place content replacement (same UID). Bumps `updated_at_ns`. NOT a revision — same trace, new content. |
|
||||||
|
| `Revise(predecessorUID, content, tags...)` | New UID with `predecessor_uid` set. Old trace stays accessible via History. Failure modes: predecessor missing → error; predecessor retired → still allowed (revisions of retired traces are valid). |
|
||||||
|
| `Retire(uid)` | Sets `retired=true`. Excluded from `Search` by default; still accessible via `Get` and `History`. |
|
||||||
|
| `Get(uid)` | Returns the trace (including if retired); error on missing. |
|
||||||
|
| `History(uid)` | Walks `predecessor_uid` chain backward, returns slice [self, parent, grandparent, ...]. Cycle-detected via visited-set; returns error on cycle (which only happens if persistence file was hand-edited). |
|
||||||
|
| `Search(filter)` | Returns matching traces. Default excludes retired; opt in via `IncludeRetired: true`. Filters: tag-match, content-substring, time range. |
|
||||||
|
|
||||||
|
### Why Mem0-style + Why these specific ops
|
||||||
|
|
||||||
|
- **Mem0** (memory pattern from the OpenAI Memories paper / Mem0 lib)
|
||||||
|
is the canonical "agent memory" interface for the same reason
|
||||||
|
Markdown is the canonical text format: it's the lowest-common-
|
||||||
|
denominator that the entire ecosystem assumes. Adopting it lets
|
||||||
|
agent loops written against any Mem0-aware substrate work here.
|
||||||
|
- Update vs Revise are deliberately separate. Update is "I noticed
|
||||||
|
a typo in my note." Revise is "I now believe something different
|
||||||
|
than I did when I wrote this; preserve the old belief for audit."
|
||||||
|
Conflating them loses the audit trail.
|
||||||
|
- Retire vs Delete is deliberate. Retire stops a trace from
|
||||||
|
surfacing in search but preserves it for history reconstruction.
|
||||||
|
Delete (which we don't expose) would break references.
|
||||||
|
|
||||||
|
### Trace data shape
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Trace struct {
|
||||||
|
UID string // UUID v4 unless caller provides one
|
||||||
|
Content json.RawMessage // opaque, schema is caller's contract
|
||||||
|
PredecessorUID string // empty if root revision
|
||||||
|
CreatedAtNs int64
|
||||||
|
UpdatedAtNs int64
|
||||||
|
Retired bool
|
||||||
|
ReplayCount int // ≥1 for any stored trace
|
||||||
|
Tags []string // for Search
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Content` is opaque JSON (not a struct) so callers can store any
|
||||||
|
shape — the data model doesn't constrain semantics. Callers add
|
||||||
|
their own validators on top.
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
JSONL append-only log under `_pathway/<store_name>.jsonl`. Each
|
||||||
|
mutation appends one JSON line:
|
||||||
|
|
||||||
|
```
|
||||||
|
{"op":"add", "trace":{...}}
|
||||||
|
{"op":"update", "uid":"…", "content":"…"}
|
||||||
|
{"op":"revise", "trace":{…}} # trace.PredecessorUID is set
|
||||||
|
{"op":"retire", "uid":"…"}
|
||||||
|
{"op":"replay", "uid":"…"} # idempotent re-add hit
|
||||||
|
```
|
||||||
|
|
||||||
|
On startup, replay every line in order, building in-memory state.
|
||||||
|
A malformed line logs a warn and is skipped; load continues.
|
||||||
|
Corruption tolerance is non-optional — partial state is better
|
||||||
|
than no state for an agent substrate.
|
||||||
|
|
||||||
|
Compaction is a future concern. A 100K-trace log replays in
|
||||||
|
seconds; below that scale, JSONL append is the simplest correct
|
||||||
|
choice. When compaction lands, the format will be: snapshot file
|
||||||
|
(full state JSON) + tail JSONL since snapshot. Detect snapshot,
|
||||||
|
load it, then replay tail.
|
||||||
|
|
||||||
|
### Cycle safety
|
||||||
|
|
||||||
|
UIDs are generated server-side via `uuid.New()` (existing dep —
|
||||||
|
catalogd uses it). New UID for every Add and Revise. The data
|
||||||
|
model itself can't form cycles — every Revise points at an
|
||||||
|
EXISTING uid, and the new uid didn't exist a moment ago.
|
||||||
|
|
||||||
|
History walks defensively anyway: visited-set tracks UIDs seen
|
||||||
|
this walk; if we encounter a duplicate, return error. Protects
|
||||||
|
against corruption (manual edit, bug in a future op) without
|
||||||
|
constraining the happy path.
|
||||||
|
|
||||||
|
### Storage location
|
||||||
|
|
||||||
|
JSONL file path is configurable per store. Default:
|
||||||
|
`/var/lib/lakehouse/pathway/<name>.jsonl` for prod; tests use
|
||||||
|
`t.TempDir()`. Persistence is OPTIONAL — empty path means
|
||||||
|
in-memory only (matches vectord G1's pattern).
|
||||||
|
|
||||||
|
### What this ADR does NOT do
|
||||||
|
|
||||||
|
- **No HTTP surface decision.** Whether `cmd/pathwayd` is its own
|
||||||
|
binary or routes get added to `cmd/vectord` is the next ADR's
|
||||||
|
concern. The substrate is a pure library either way.
|
||||||
|
- **No vector index integration.** Pathway traces can carry a
|
||||||
|
vector embedding in `Content` (caller decides), but this ADR
|
||||||
|
doesn't define how the substrate integrates with `vectord`'s
|
||||||
|
HNSW indexes. That's the staffing co-pilot's design problem
|
||||||
|
when those layers compose.
|
||||||
|
- **No agent-loop semantics.** "When does an agent ADD vs
|
||||||
|
REVISE?" is a workflow decision, not a substrate decision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-005: Observer fail-safe semantics
|
||||||
|
|
||||||
|
**Date:** 2026-04-30
|
||||||
|
**Status:** RATIFIED
|
||||||
|
**Scope:** `internal/observer` (Store, Persistor) + `internal/workflow` (Runner) + `cmd/observerd`
|
||||||
|
|
||||||
|
The Rust legacy had a documented "verdict:accept on crash" anti-pattern:
|
||||||
|
when the observer crashed mid-evaluation, the upstream interpreted the
|
||||||
|
missing verdict as implicit acceptance. Several silent regressions traced
|
||||||
|
to it. The Go observer's role is structurally different — it is a
|
||||||
|
**witness** (records what happened) rather than a **gate** (decides
|
||||||
|
accept/reject) — but adjacent fail-safe decisions still need locking
|
||||||
|
now that observerd is on the prod-realistic stack via the lift harness
|
||||||
|
(commit `b2e45f7`, 2026-04-30). This ADR ratifies the current behavior
|
||||||
|
and locks the rationale so future consumers don't break the invariant
|
||||||
|
by flipping the defaults.
|
||||||
|
|
||||||
|
### Decision 5.1 — Persist failure is logged-not-fatal; ring is the in-flight source of truth
|
||||||
|
|
||||||
|
Already implemented (`internal/observer/store.go:60-67`). Locked:
|
||||||
|
|
||||||
|
- If `persistor.Append` fails, log a warning and continue. Do NOT
|
||||||
|
return an error to the caller of `Store.Record`.
|
||||||
|
- The in-memory ring buffer is the source of truth in flight; the
|
||||||
|
JSONL is a best-effort durability shadow.
|
||||||
|
- Operators who need fail-closed audit-grade trails configure that
|
||||||
|
mode through a future opt-in (deferred to a later ADR; not the
|
||||||
|
G0/G1/G2 default).
|
||||||
|
|
||||||
|
**Why fail-open here:** the observer's job is to keep recording even
|
||||||
|
when the disk hiccups. A `persist-fail-fatal` mode would translate
|
||||||
|
every transient I/O blip into an observer-blackout, which is strictly
|
||||||
|
worse for the witness role than missing a few persisted entries — the
|
||||||
|
ring still has them, and operators can drain it on restart.
|
||||||
|
|
||||||
|
**Why this isn't the Rust anti-pattern:** the Go observer doesn't
|
||||||
|
emit verdicts. A persist failure here means "we recorded fewer rows
|
||||||
|
on disk than in memory," not "we accepted something we shouldn't have."
|
||||||
|
|
||||||
|
### Decision 5.2 — Mode failure in workflow.Runner: `Success = (Error == "")`, no panic-swallow path
|
||||||
|
|
||||||
|
Already implemented (`internal/workflow/runner.go`). Locked:
|
||||||
|
|
||||||
|
- Mode errors are caught by the runner and surfaced via the node's
|
||||||
|
`Error` field; `Success` is the boolean derived from `Error == ""`.
|
||||||
|
- `observerd` records an `ObservedOp` per node with `Success: false`
|
||||||
|
and the error string when a mode fails.
|
||||||
|
- Cycles, missing-deps, and unknown modes are aborting errors → 4xx
|
||||||
|
from `/observer/workflow/run` with the failure encoded in the JSON
|
||||||
|
response.
|
||||||
|
|
||||||
|
**Why this is the explicit anti-Rust:** allowing a mode to silently
|
||||||
|
swallow its panic and report `Success: true` is exactly how the Rust
|
||||||
|
"verdict:accept on crash" pattern manifests. Forcing the runner to
|
||||||
|
record `Success: false` on error makes the failure observable to
|
||||||
|
downstream consumers (observerd queries, scrum review, distillation
|
||||||
|
selection) instead of laundering it into a fake success.
|
||||||
|
|
||||||
|
### Decision 5.3 — Provenance is one-row-per-node, recorded post-run
|
||||||
|
|
||||||
|
Already implemented (`cmd/observerd/main.go:140-154`). Locked:
|
||||||
|
|
||||||
|
- `runner.Run` returns the full `RunResult` with per-node Success/Error;
|
||||||
|
`handleWorkflowRun` then iterates `res.Nodes` and `store.Record`s an
|
||||||
|
`ObservedOp` per node.
|
||||||
|
- One row per node, NOT a single per-workflow catch-all. A workflow with
|
||||||
|
N nodes produces N audit rows.
|
||||||
|
- Crash semantics:
|
||||||
|
- Crash *during* `runner.Run` → no provenance recorded; queries see
|
||||||
|
absence, not a false acceptance.
|
||||||
|
- Crash *during* the recording loop → some nodes recorded, some
|
||||||
|
absent; queries see partial provenance, again not a false
|
||||||
|
acceptance.
|
||||||
|
- Recovery: re-run the whole workflow. No incremental resume in G0/G1/G2.
|
||||||
|
|
||||||
|
**Why one row per node:** debugging a partial workflow is a one-grep
|
||||||
|
operation when each node has its own row. A single catch-all row would
|
||||||
|
be exactly the Rust anti-pattern surface — "we accepted this workflow"
|
||||||
|
records that survive partial crashes look identical to genuine
|
||||||
|
acceptances. Per-node-row makes that structurally impossible.
|
||||||
|
|
||||||
|
**Known gap, not yet a follow-up ADR:** recording happens after
|
||||||
|
`runner.Run` returns, not as each node completes. A long workflow with
|
||||||
|
late-stage failure currently records nodes that already finished only
|
||||||
|
once the runner returns. For G0/G1/G2 substrate this is fine —
|
||||||
|
workflows are short. When workflows get long enough that mid-run
|
||||||
|
visibility matters, a streaming-record callback is the right shape.
|
||||||
|
|
||||||
|
### Decision 5.4 — `/observer/event` accepts even when the ring is full
|
||||||
|
|
||||||
|
Already implemented via `Store.Record`'s shift-left eviction. Locked:
|
||||||
|
|
||||||
|
- Ring overflow is normal operation: oldest evicted, newest accepted.
|
||||||
|
- 200 OK from `/observer/event` means "we accepted into the ring"; it
|
||||||
|
does NOT promise "we persisted." Persistence remains best-effort
|
||||||
|
per Decision 5.1.
|
||||||
|
- 4xx is reserved for malformed `ObservedOp` payloads (validation
|
||||||
|
failures).
|
||||||
|
|
||||||
|
**Why accept-on-full:** treating a full ring as a 503 would translate
|
||||||
|
every brief activity burst into client errors, which is exactly the
|
||||||
|
wrong direction for an audit witness — the witness's job is to never
|
||||||
|
refuse to write, only to lose oldest data when capacity binds.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- **Persist-required mode** — caller-configurable fail-closed for
|
||||||
|
audit-grade workloads. The right approach when this lands is an
|
||||||
|
opt-in on `Store` construction, leaving the default fail-open.
|
||||||
|
Deferred to a future ADR.
|
||||||
|
- **Distributed ring with WAL** — persist before accept-into-ring,
|
||||||
|
sync semantics. Too heavy for G0/G1 and breaks the ring's "in-flight
|
||||||
|
source of truth" property.
|
||||||
|
- **Mode-result schema with explicit verdict field** — would force
|
||||||
|
every mode to declare accept/reject. Overengineered for the witness
|
||||||
|
role and reintroduces the gate-vs-witness confusion this ADR is
|
||||||
|
trying to avoid.
|
||||||
|
|
||||||
|
### What this ADR does NOT do
|
||||||
|
|
||||||
|
- **No retention policy.** "How long do we keep observer entries on
|
||||||
|
disk?" is a separate operations decision.
|
||||||
|
- **No mode-level retry.** If a mode fails, the runner records that
|
||||||
|
and moves on. Whether to retry is a workflow-definition concern
|
||||||
|
(Archon-style retry policies in the YAML), not the runner's.
|
||||||
|
- **No cross-process recovery.** A crashed observerd loses the ring;
|
||||||
|
the persistor preserves what it managed to write. Operators read the
|
||||||
|
JSONL after restart, not query a dead daemon.
|
||||||
|
- **No persist-required opt-in.** Mentioned in alternatives; lands in
|
||||||
|
a separate ADR when an audit-grade consumer requires it.
|
||||||
|
|
||||||
|
### How this closes the OPEN list
|
||||||
|
|
||||||
|
STATE_OF_PLAY listed ADR-005 as a doc-only gate before observer wired
|
||||||
|
into production paths. The 2026-04-30 lift run wired observerd into the
|
||||||
|
prod-realistic harness boot, which means observer is now on the data
|
||||||
|
path for every reality test workflow. This ADR locks the fail-safe
|
||||||
|
invariants before the next consumer (scrum runner, distillation rebuild,
|
||||||
|
or a real production workflow) takes a hard behavioral dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-006: Auth posture for non-loopback deploy
|
||||||
|
|
||||||
|
**Date:** 2026-04-30
|
||||||
|
**Status:** RATIFIED
|
||||||
|
**Scope:** `internal/shared/auth.go` + `internal/shared/bind.go` + every `cmd/<bin>/main.go`'s `shared.Run` call site
|
||||||
|
|
||||||
|
ADR-003 locked the substrate (Bearer token + IP allowlist, opt-in via
|
||||||
|
`cfg.Auth.Token`/`cfg.Auth.AllowedIPs`, `/health` exempt). ADR-006
|
||||||
|
ratifies the **operator playbook + deploy-time invariants** — what
|
||||||
|
gets enforced when, what operators set where, what happens when keys
|
||||||
|
rotate. Required because Sprint 4 deployment work (REPLICATION.md,
|
||||||
|
systemd units, Dockerfile) needs a locked auth posture before it
|
||||||
|
touches production-shaped configs.
|
||||||
|
|
||||||
|
### Decision 6.1 — Non-loopback bind requires `auth.token`; the gate is mechanical
|
||||||
|
|
||||||
|
Already implemented in `requireAuthOnNonLoopback` (`internal/shared/bind.go:58-67`).
|
||||||
|
Locked:
|
||||||
|
|
||||||
|
- Any binary that binds anything other than `127.0.0.0/8` / `::1` /
|
||||||
|
`localhost` MUST have `cfg.Auth.Token != ""`. Empty-token +
|
||||||
|
non-loopback-bind = startup error, not silent insecure mode.
|
||||||
|
- The check fires in `shared.Run` BEFORE `http.Server.Serve`, so a
|
||||||
|
misconfigured binary fails fast at startup rather than serving
|
||||||
|
one request.
|
||||||
|
- Pairs with `requireLoopbackOrOverride`: that gate refuses any
|
||||||
|
non-loopback bind without `LH_<NAME>_ALLOW_NONLOOPBACK=1`. Together
|
||||||
|
they make the audit's R-001+R-007 worst case (queryd `/sql` =
|
||||||
|
RCE-equivalent off-loopback with no auth) mechanically impossible.
|
||||||
|
|
||||||
|
**Why mechanical, not policy:** policy gates rely on operator
|
||||||
|
discipline. The substrate gates work even when an operator copies a
|
||||||
|
dev `lakehouse.toml` into prod and forgets to set the token —
|
||||||
|
binary refuses to start, error message names the env override.
|
||||||
|
|
||||||
|
### Decision 6.2 — Token comes from `cfg.Auth.Token` populated by env or secrets file
|
||||||
|
|
||||||
|
Locked:
|
||||||
|
|
||||||
|
- Operators do NOT put the production token in `lakehouse.toml`
|
||||||
|
directly. The TOML field is empty in the committed file; the
|
||||||
|
daemon's systemd unit sets `AUTH_TOKEN` (or whatever
|
||||||
|
`cfg.Auth.TokenEnv` names) via `EnvironmentFile=` pointing at
|
||||||
|
`/etc/lakehouse/auth.env` (mode 0600, root-owned).
|
||||||
|
- Same pattern as `chatd`'s provider keys (`OPENROUTER_API_KEY` etc.):
|
||||||
|
TOML names the env var, systemd loads the env file.
|
||||||
|
- Justification: keeps secrets out of git + out of the running
|
||||||
|
process's command line + audit-able via filesystem ACLs.
|
||||||
|
|
||||||
|
### Decision 6.3 — `AllowedIPs` is the inter-service gate; `Token` is the cross-trust-boundary gate
|
||||||
|
|
||||||
|
Locked:
|
||||||
|
|
||||||
|
- Same-box deploys (10 daemons all on one host, all on `127.0.0.0/8`
|
||||||
|
or a private subnet) use **`AllowedIPs` only**. Each daemon's
|
||||||
|
`cfg.Auth.AllowedIPs` lists the gateway's address (and any other
|
||||||
|
daemon that legitimately calls it). No token shared between
|
||||||
|
internal services.
|
||||||
|
- Gateway-to-external traffic (a coordinator UI in another VPC,
|
||||||
|
a user's browser, an external integrator) goes through
|
||||||
|
**Bearer token**. The token is per-tenant; rotation is per-tenant.
|
||||||
|
- Mixed: a service can require BOTH (allowlist AND token) — the
|
||||||
|
middleware logic is `allowed = ip_allowed && token_valid` when
|
||||||
|
both are set. Use this for the gateway when binding non-loopback.
|
||||||
|
|
||||||
|
**Why split:** token rotation is operationally expensive (every
|
||||||
|
caller updates a secret). IP allowlist rotation is free if the
|
||||||
|
network topology is stable. Splitting them by trust boundary lets
|
||||||
|
internal services treat allowlist drift as a network change while
|
||||||
|
external callers handle token rotation as a credential change.
|
||||||
|
|
||||||
|
### Decision 6.4 — `/health` is unauthenticated; everything else under `shared.Run` is gated
|
||||||
|
|
||||||
|
Already implemented (`internal/shared/server.go:84-92`). Locked:
|
||||||
|
|
||||||
|
- Load balancers + monitor probes hit `/health` without a token.
|
||||||
|
The route returns `{"status":"ok","service":"<name>"}` and nothing
|
||||||
|
about service state — no version, no commit, no internal counts.
|
||||||
|
- Every other route registered via `shared.Run`'s `register`
|
||||||
|
callback lives inside the auth-gated chi.Group. New routes
|
||||||
|
inherit auth automatically; new daemons inherit it via `shared.Run`.
|
||||||
|
- A daemon that needs a public route MUST add it to the outer router
|
||||||
|
before the `register` group, with a code comment explaining the
|
||||||
|
exemption. There are no others today.
|
||||||
|
|
||||||
|
### Decision 6.5 — Token rotation is operator-staged; old + new accepted during the window
|
||||||
|
|
||||||
|
Not yet implemented; locked as a Sprint 4 follow-up:
|
||||||
|
|
||||||
|
- Operators stage a rotation by adding a second token to
|
||||||
|
`cfg.Auth.SecondaryTokens []string`. Both primary and secondary
|
||||||
|
pass auth during the window.
|
||||||
|
- After every caller is updated to the new token, operators
|
||||||
|
promote secondary → primary and clear secondary. A second
|
||||||
|
rotation can begin.
|
||||||
|
- Rolling restart not required; daemons reload `cfg.Auth` on
|
||||||
|
SIGHUP (also a Sprint 4 follow-up — currently they re-read on
|
||||||
|
restart only).
|
||||||
|
|
||||||
|
**Why dual-token instead of just single-rotation:** caller pool can be
|
||||||
|
large (gateway + observerd + scrum runner + UI + external integrators).
|
||||||
|
A single-token rotation forces a flag-day. Dual-token windows let
|
||||||
|
operators rotate gradually and abort on failure.
|
||||||
|
|
||||||
|
### Decision 6.6 — TLS is the network operator's job, not ours
|
||||||
|
|
||||||
|
Locked:
|
||||||
|
|
||||||
|
- Daemons speak HTTP, not HTTPS. TLS termination happens at the
|
||||||
|
network edge (nginx / Caddy / cloud LB), not in the Go process.
|
||||||
|
- Internal daemon-to-daemon traffic stays on plaintext HTTP because
|
||||||
|
it's all on `127.0.0.0/8` or a private subnet (per Decision 6.3).
|
||||||
|
- Justification: TLS in-process means cert management, rotation,
|
||||||
|
reload — all undifferentiated lift that nginx already solves
|
||||||
|
better. The Bearer token + allowlist gates are sufficient when
|
||||||
|
combined with a TLS-terminating reverse proxy.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- **mTLS for inter-service auth** — every daemon issues + verifies
|
||||||
|
certs. Solves token-rotation pain but adds cert lifecycle as a
|
||||||
|
problem. Allowlist + plaintext on the private network is cheaper
|
||||||
|
and gets the same threat-model coverage.
|
||||||
|
- **JWT-only** — JWTs let callers carry richer claims (tenant id,
|
||||||
|
expiry, scopes). Overkill for the current threat model; the
|
||||||
|
Bearer token + allowlist split is honest about what each layer
|
||||||
|
actually defends against. Revisit when multi-tenant gateway
|
||||||
|
features land.
|
||||||
|
- **No auth, network is the boundary** — works for G0 dev and the
|
||||||
|
current single-box deployment. ADR-006 explicitly does NOT
|
||||||
|
recommend this for non-loopback prod (the mechanical gate
|
||||||
|
refuses it).
|
||||||
|
|
||||||
|
### What this ADR does NOT do
|
||||||
|
|
||||||
|
- **Does not specify how the gateway authenticates external callers.**
|
||||||
|
Token-vs-mTLS-vs-OAuth at the public edge is a separate decision
|
||||||
|
driven by who-calls-us. ADR-006 is about the inter-service +
|
||||||
|
same-trust-domain posture.
|
||||||
|
- **Does not implement token rotation hot-reload.** Decision 6.5
|
||||||
|
documents the design; the implementation is Sprint 4 work.
|
||||||
|
- **Does not lock TLS termination details.** Where + how nginx/Caddy
|
||||||
|
goes is ops infrastructure, not ADR territory.
|
||||||
|
|
||||||
|
### How this closes the OPEN list
|
||||||
|
|
||||||
|
STATE_OF_PLAY listed ADR-006 as the gate before any Go binary binds
|
||||||
|
non-loopback in prod. The substrate gates were already present (R-001
|
||||||
|
+ R-007 enforced via `requireLoopbackOrOverride` +
|
||||||
|
`requireAuthOnNonLoopback`); this ADR locks the operator playbook
|
||||||
|
that turns those gates into a deployable posture. Sprint 4 can now
|
||||||
|
write systemd units that set `AUTH_TOKEN` from `EnvironmentFile=`
|
||||||
|
without re-litigating the design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+55
@@ -9,6 +9,61 @@ estimates, library choices, and acceptance gates.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Product vision — what we're actually building
|
||||||
|
|
||||||
|
**The Go refactor isn't the goal. The goal is a small-model-driven autonomous pipeline that gets better with each run, with frontier models in audit/oversight and humans triaged in only for the genuinely abstract cases.**
|
||||||
|
|
||||||
|
The Rust Lakehouse already has most of the pieces:
|
||||||
|
- **Pathway memory** (`internal/pathway` in Go, 88 Rust traces preserved) — what we tried, what worked
|
||||||
|
- **Matrix indexer** (SPEC §3.4) — multi-corpus retrieve+merge that gives the small model the right knowledge slice for *this* task
|
||||||
|
- **Observer** — watches runs, refines configs, escalates
|
||||||
|
- **Distillation v1.0.0** (`e7636f2`) — turns successful runs into denser playbooks
|
||||||
|
- **Auditor cross-lineage fabric** — Kimi/Haiku/Opus oversight on small-model outputs
|
||||||
|
|
||||||
|
What the Go refactor is FOR: a second-language pass surfaces architectural weaknesses that Rust hid. The pipeline has to pull together cleanly *as a pipeline* — not as 15 crates that happen to interact.
|
||||||
|
|
||||||
|
### The five-loop substrate
|
||||||
|
|
||||||
|
1. **Knowledge pathway loop** — pathway memory + matrix indexer give the small model context for the task. Pathway answers "what worked last time?"; matrix answers "what's relevant now?"
|
||||||
|
2. **Execution loop** — small model runs on focused context. Frontier API calls are reserved for audit/escalation, not the inner loop. Cost + rate limits stay sane.
|
||||||
|
3. **Observer loop** — watches each run, refines the configs (matrix corpus picks, downgrade gate, prompt mold) that got the model to a good pathway. Outputs new config, not new prompt.
|
||||||
|
4. **Rating + distillation loop** — successful outcomes get scored and folded back into the playbook substrate. The playbook gets denser; the next run starts smarter.
|
||||||
|
5. **Drift loop** — quantify when the distilled playbook stops matching reality (codebase changed, contracts shifted, profiles updated). Drift is a *measured* signal, not "hope nothing broke."
|
||||||
|
|
||||||
|
### The gate
|
||||||
|
|
||||||
|
**The playbook + matrix indexer must produce the results we're looking for.** That's the single load-bearing acceptance criterion. Throughput, scaling, code elegance — all secondary. If a deep-field reality test on the 500K corpus surfaces wrong answers, the loop isn't working and we fix that before adding anything else.
|
||||||
|
|
||||||
|
### Observer as system resource (clarified 2026-04-29)
|
||||||
|
|
||||||
|
The observer is not a service among services — it's a *system
|
||||||
|
resource*. Its job is to be objective about the process: watch
|
||||||
|
everything, record measurements, surface what worked vs what
|
||||||
|
didn't, feed the KB so the playbook substrate can decide the
|
||||||
|
right pathway to the correct outcome.
|
||||||
|
|
||||||
|
The bare-bones observerd shipped in `bc9ab93` (event ingest +
|
||||||
|
stats) is the substrate for this. The architectural pattern
|
||||||
|
that grows it into the full "objective measurement engine" is
|
||||||
|
the **multi-pass workflow runner** documented in SPEC §3.8 —
|
||||||
|
inspired by Archon (`/home/profit/external/Archon`) and proven
|
||||||
|
in the Rust `observer-kb` branch's Python prototypes (`deep_analysis.py`,
|
||||||
|
`extract_knowledge.py`, `process_knowledge.py`).
|
||||||
|
|
||||||
|
The pipeline mode-chain (extract → validator → hallucination →
|
||||||
|
consensus → redteam → pipeline → render) IS how the observer
|
||||||
|
makes actionable decisions: each mode pass is a deterministic
|
||||||
|
measurement; what survives the gauntlet is what feeds the KB.
|
||||||
|
|
||||||
|
### Triage / human-in-loop
|
||||||
|
|
||||||
|
Most cases are abstract enough that small-model + pathway + matrix can complete them. Some can't — they need a human. The system's job is to **identify which is which** and only escalate the second class. Frontier models partially solve this internally with their thinking loops; we're externalizing it so:
|
||||||
|
- Small models are swappable (vendor independence)
|
||||||
|
- Drift is measurable (quantitative signal, not vibes)
|
||||||
|
- Each loop iteration is auditable (the pathway memory IS the audit trail)
|
||||||
|
|
||||||
|
This is what the auditor cross-lineage fabric proves out in Rust — Opus auto-promote on diffs >100k chars is the same pattern: triage by signal, not by guesswork.
|
||||||
|
|
||||||
## Direction pivot — why this PRD exists
|
## Direction pivot — why this PRD exists
|
||||||
|
|
||||||
The Rust-first Lakehouse (15 crates, ~24 unmerged commits past PR #11,
|
The Rust-first Lakehouse (15 crates, ~24 unmerged commits past PR #11,
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Coordinator session log — `coordinator_sessions.jsonl`
|
||||||
|
|
||||||
|
**Last updated:** 2026-05-02 · **Schema:** `session.iterate.v1` · **Writer:** `internal/validator.SessionLogger` · **Producer:** validatord `/v1/iterate`
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The Langfuse trace tree is the live view: per-session, you can scroll
|
||||||
|
the retry chain and inspect every sub-call. But for **longitudinal
|
||||||
|
forensics** ("show me every session in the last week where the model
|
||||||
|
guessed a real worker without a retry," or "find sessions where
|
||||||
|
validation rejected three times in a row"), Langfuse's UI doesn't
|
||||||
|
scale — you need a queryable data plane.
|
||||||
|
|
||||||
|
This JSONL is that plane. One row per `/v1/iterate` session, append-
|
||||||
|
only, DuckDB-friendly.
|
||||||
|
|
||||||
|
## Where
|
||||||
|
|
||||||
|
Configurable via `[validatord].session_log_path` in `lakehouse.toml`.
|
||||||
|
Empty = disabled (best-effort posture; a missing log never blocks an
|
||||||
|
iterate request). Production:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[validatord]
|
||||||
|
session_log_path = "/var/lib/lakehouse/validator/sessions.jsonl"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema (v1)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"schema": "session.iterate.v1",
|
||||||
|
"session_id": "<Langfuse trace_id>", // join key to Langfuse
|
||||||
|
"timestamp": "2026-05-02T07:30:00.123456Z",
|
||||||
|
"daemon": "validatord",
|
||||||
|
"kind": "fill | email | playbook",
|
||||||
|
"model": "qwen3.5:latest",
|
||||||
|
"provider": "ollama",
|
||||||
|
"prompt": "produce a fill artifact ...", // truncated to 4000 chars
|
||||||
|
"iterations": 3, // attempts spent
|
||||||
|
"max_iterations": 3, // cap per request
|
||||||
|
"final_verdict": "accepted | max_iter_exhausted | infra_error",
|
||||||
|
"attempts": [
|
||||||
|
{ "iteration": 0, "verdict_kind": "validation_failed",
|
||||||
|
"error": "consistency: candidate_id W-X not in roster",
|
||||||
|
"span_id": "abc..." },
|
||||||
|
{ "iteration": 1, "verdict_kind": "validation_failed",
|
||||||
|
"error": "consistency: city mismatch", "span_id": "def..." },
|
||||||
|
{ "iteration": 2, "verdict_kind": "accepted", "span_id": "ghi..." }
|
||||||
|
],
|
||||||
|
"artifact": { /* final accepted artifact, omitted on failure */ },
|
||||||
|
"grounded_in_roster": true, // null when N/A (email/playbook)
|
||||||
|
"duration_ms": 2840
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field semantics
|
||||||
|
|
||||||
|
| Field | When set | What it means |
|
||||||
|
|---|---|---|
|
||||||
|
| `session_id` | always | Langfuse trace id. Pivot to live trace tree by URL: `${LANGFUSE_URL}/trace/<session_id>`. |
|
||||||
|
| `final_verdict=accepted` | success | Loop converged within `max_iterations`. `artifact` is non-null. |
|
||||||
|
| `final_verdict=max_iter_exhausted` | failure | Loop hit the cap without passing validation. `artifact` is omitted. |
|
||||||
|
| `final_verdict=infra_error` | failure | Chat hop or other infra crashed. Single attempt with `verdict_kind=infra_error`. |
|
||||||
|
| `grounded_in_roster=true` | fill kind, success | Every `candidate_id` in the artifact exists in `WorkerLookup`. |
|
||||||
|
| `grounded_in_roster=false` | fill kind, anomaly | Phantom or otherwise-invalid candidate IDs (shouldn't happen — FillValidator catches these — but the explicit check defends against future validator weakening). |
|
||||||
|
| `grounded_in_roster=null` (omitted) | non-fill kinds, or failure | The roster check doesn't apply or wasn't run. |
|
||||||
|
|
||||||
|
## DuckDB queries
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Read the log directly via DuckDB's read_json_auto.
|
||||||
|
ATTACH ':memory:' AS sessions;
|
||||||
|
SELECT * FROM read_json_auto(
|
||||||
|
'/var/lib/lakehouse/validator/sessions.jsonl', format='newline_delimited'
|
||||||
|
) LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Did the validator catch every phantom worker?"
|
||||||
|
```sql
|
||||||
|
-- Sessions where iteration 0's verdict was validation_failed AND
|
||||||
|
-- the error mentions 'phantom' or 'consistency'. If grounded=true on
|
||||||
|
-- the same session's final state, the model recovered.
|
||||||
|
SELECT session_id, model, iterations, grounded_in_roster, final_verdict
|
||||||
|
FROM read_json_auto('sessions.jsonl', format='newline_delimited')
|
||||||
|
WHERE final_verdict = 'accepted'
|
||||||
|
AND iterations > 1
|
||||||
|
AND list_contains(
|
||||||
|
list_transform(attempts,
|
||||||
|
x -> x.error LIKE '%consistency%' OR x.error LIKE '%phantom%'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### "First-shot success rate per model" (the "did the corpus give it enough" gate)
|
||||||
|
```sql
|
||||||
|
SELECT model,
|
||||||
|
COUNT(*) AS sessions,
|
||||||
|
SUM(CASE WHEN iterations = 1 AND final_verdict = 'accepted' THEN 1 ELSE 0 END) AS first_shot,
|
||||||
|
ROUND(100.0 * SUM(CASE WHEN iterations = 1 AND final_verdict = 'accepted' THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct
|
||||||
|
FROM read_json_auto('sessions.jsonl', format='newline_delimited')
|
||||||
|
WHERE kind = 'fill'
|
||||||
|
GROUP BY model
|
||||||
|
ORDER BY pct DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Sessions that were never grounded" (the alarm query)
|
||||||
|
```sql
|
||||||
|
-- Should always be empty. If it isn't, FillValidator has a hole or
|
||||||
|
-- a different code path is bypassing the roster check.
|
||||||
|
SELECT session_id, model, iterations, attempts
|
||||||
|
FROM read_json_auto('sessions.jsonl', format='newline_delimited')
|
||||||
|
WHERE kind = 'fill'
|
||||||
|
AND final_verdict = 'accepted'
|
||||||
|
AND grounded_in_roster = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Average retry depth per model"
|
||||||
|
```sql
|
||||||
|
SELECT model, AVG(iterations) AS avg_iter, COUNT(*) AS n
|
||||||
|
FROM read_json_auto('sessions.jsonl', format='newline_delimited')
|
||||||
|
WHERE kind = 'fill' AND final_verdict = 'accepted'
|
||||||
|
GROUP BY model
|
||||||
|
ORDER BY avg_iter ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### "What did validation reject?" (failure mode breakdown)
|
||||||
|
```sql
|
||||||
|
-- Pull each rejected attempt's error string, classify by prefix.
|
||||||
|
WITH errors AS (
|
||||||
|
SELECT session_id,
|
||||||
|
model,
|
||||||
|
unnest(attempts) AS att
|
||||||
|
FROM read_json_auto('sessions.jsonl', format='newline_delimited')
|
||||||
|
)
|
||||||
|
SELECT model,
|
||||||
|
split_part(att.error, ':', 1) AS kind,
|
||||||
|
COUNT(*) AS n
|
||||||
|
FROM errors
|
||||||
|
WHERE att.verdict_kind = 'validation_failed'
|
||||||
|
GROUP BY model, kind
|
||||||
|
ORDER BY n DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- **Append-only.** No row is ever updated; storage grows linearly with iterate calls. Operators rotate via cron when the file gets unwieldy (logrotate-style).
|
||||||
|
- **Best-effort posture.** Every write goes through `slog.Warn` on failure but never blocks the iterate handler. A full disk silently drops session rows; the iterate response still ships.
|
||||||
|
- **Schema versioning.** `schema=session.iterate.v1` is the contract. Future incompatible changes bump the version; consumers should branch on the field.
|
||||||
|
- **PII consideration.** `prompt` is captured truncated to 4000 chars and the final `artifact` (when present) is captured verbatim. Operators handling PII-bearing prompts should set the path under a restricted-access volume or filter before retention.
|
||||||
|
- **Cross-runtime parity.** The Rust gateway's `/v1/iterate` does NOT yet write this file. If you want a unified longitudinal log across runtimes, port the writer to Rust (`crates/gateway/src/v1/iterate.rs`) and target the same JSONL path. ~50 LOC.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- `internal/validator/session_log.go` — writer + record types
|
||||||
|
- `internal/validator/iterate.go` — `Tracer` callback + Langfuse span emission
|
||||||
|
- `internal/shared/langfuse_middleware.go` — `X-Lakehouse-Trace-Id` header propagation (the `session_id` join key)
|
||||||
|
- `data/_kb/replay_runs.jsonl` — the *replay* tool's own JSONL (different shape, different producer); these two streams are siblings, not duplicates
|
||||||
+367
@@ -28,9 +28,11 @@ Effort scale (one engineer-week = ~40h focused work):
|
|||||||
| `queryd` | datafusion, arrow | `cmd/queryd` | **`duckdb/duckdb-go/v2`** (cgo, official) | **HARD** | high — see §3 |
|
| `queryd` | datafusion, arrow | `cmd/queryd` | **`duckdb/duckdb-go/v2`** (cgo, official) | **HARD** | high — see §3 |
|
||||||
| `ingestd` | csv, json, lopdf, postgres | `cmd/ingestd` | stdlib `encoding/csv`, `encoding/json`, `pdfcpu/pdfcpu`, `jackc/pgx/v5` | **L** | low |
|
| `ingestd` | csv, json, lopdf, postgres | `cmd/ingestd` | stdlib `encoding/csv`, `encoding/json`, `pdfcpu/pdfcpu`, `jackc/pgx/v5` | **L** | low |
|
||||||
| `vectord` | hora, arrow, hnsw | `cmd/vectord` | `coder/hnsw`, `apache/arrow-go/v18` | **L** | medium — re-validate HNSW recall |
|
| `vectord` | hora, arrow, hnsw | `cmd/vectord` | `coder/hnsw`, `apache/arrow-go/v18` | **L** | medium — re-validate HNSW recall |
|
||||||
|
| **matrix indexer** (emergent in Rust — `mode.rs` + `build_*_corpus.ts` + observer `/relevance`) | scripts/build_*_corpus.ts, crates/gateway/src/v1/mode.rs, mcp-server/observer.ts | `internal/matrix/` + gateway routes (`/v1/matrix/*`) | stdlib + vectord client | **L** | medium — see §3.4. Corpus-as-shard composer; relevance filter; strong-model downgrade gate; multi-corpus retrieve+merge. The learning-loop layer that lifts vectord from "static index" to "meta-index that learns from playbooks." |
|
||||||
| `vectord-lance` | lance | **DROPPED** | n/a | n/a | n/a — Parquet+HNSW only |
|
| `vectord-lance` | lance | **DROPPED** | n/a | n/a | n/a — Parquet+HNSW only |
|
||||||
| `journald` | parquet, arrow | `cmd/journald` | `apache/arrow-go/v18` | **M** | low |
|
| `journald` | parquet, arrow | `cmd/journald` | `apache/arrow-go/v18` | **M** | low |
|
||||||
| `aibridge` | reqwest | library | `net/http` + connection pool · `anthropics/anthropic-sdk-go` available for direct Claude calls (currently routed via opencode) | **S** | low |
|
| `aibridge` | reqwest | library | `net/http` + connection pool · `anthropics/anthropic-sdk-go` available for direct Claude calls (currently routed via opencode) | **S** | low |
|
||||||
|
| **chatd** (Phase 4 — multi-provider LLM dispatcher) | crates/gateway/src/v1/{ollama_cloud,openrouter,opencode}.rs | `cmd/chatd` + `internal/chat/` | stdlib `net/http` only | **DONE** | medium — see §3.9. 5-provider routing (ollama / ollama_cloud / openrouter / opencode / kimi) by model-name prefix or `:cloud` suffix. Replaces the Rust gateway's `v1::` adapters. |
|
||||||
| `validator` | parquet, custom | library | `apache/arrow-go/v18` parquet reader | **M** | low — port the 24 unit tests as gates |
|
| `validator` | parquet, custom | library | `apache/arrow-go/v18` parquet reader | **M** | low — port the 24 unit tests as gates |
|
||||||
| `truth` | tomli, custom DSL | library | `pelletier/go-toml/v2` | **M** | low |
|
| `truth` | tomli, custom DSL | library | `pelletier/go-toml/v2` | **M** | low |
|
||||||
| `proto` | tonic-build | `proto/` + `protoc-gen-go` | `buf` + `protoc-gen-go-grpc` | **S** | low |
|
| `proto` | tonic-build | `proto/` + `protoc-gen-go` | `buf` + `protoc-gen-go-grpc` | **S** | low |
|
||||||
@@ -116,6 +118,371 @@ needs revisiting in Go to confirm the sidecar format we ship.
|
|||||||
- G3.2.C — Recall@10 within 2% of Rust baseline on
|
- G3.2.C — Recall@10 within 2% of Rust baseline on
|
||||||
`lakehouse_arch_v1`
|
`lakehouse_arch_v1`
|
||||||
|
|
||||||
|
### §3.4 — Matrix indexer (corpus-as-shard composer)
|
||||||
|
|
||||||
|
**What it is.** The matrix indexer is the layer above `vectord` that
|
||||||
|
turns a fleet of single-corpus HNSW indexes into a learning meta-index.
|
||||||
|
In the Rust system this is emergent — split between corpus builders
|
||||||
|
(`scripts/build_*_corpus.ts`), the mode runner (`crates/gateway/src/v1/mode.rs`),
|
||||||
|
the observer relevance endpoint (`mcp-server/observer.ts`), and the
|
||||||
|
strong-model downgrade gate (`mode.rs::execute`). In Go we name it
|
||||||
|
explicitly so future sessions don't reduce it to "vectord."
|
||||||
|
|
||||||
|
**Why corpus-as-shard, not shard-by-id.** Sharding a single index by
|
||||||
|
hash(id) is a pure throughput hack with a recall tax. Sharding by
|
||||||
|
corpus is the existing retrieval shape — `lakehouse_arch_v1`,
|
||||||
|
`lakehouse_symbols_v1`, `scrum_findings_v1`, `lakehouse_answers_v1`,
|
||||||
|
`kb_team_runs_v1`, `successful_playbooks_live`, etc. — each with
|
||||||
|
distinct topology and a distinct retrieval intent. Concurrent Adds
|
||||||
|
parallelize naturally because they go to different corpora; the
|
||||||
|
matrix layer's job is to retrieve+merge across them, filter for
|
||||||
|
relevance, and downgrade composition when strong models prove the
|
||||||
|
matrix is anti-additive.
|
||||||
|
|
||||||
|
**Components to port (in dependency order):**
|
||||||
|
|
||||||
|
1. **Corpus builders** — Go equivalents of `scripts/build_*_corpus.ts`.
|
||||||
|
For each named corpus, a builder that reads source, splits into
|
||||||
|
chunks per the corpus's schema, embeds via `/v1/embed`, and adds
|
||||||
|
to a vectord index of the same name. Effort: **M** for the first
|
||||||
|
builder, **S** for each subsequent.
|
||||||
|
|
||||||
|
2. **Multi-corpus retrieve+merge** (`internal/matrix/retrieve.go`) —
|
||||||
|
given a query and a list of corpus names, search each at top_k=K,
|
||||||
|
merge by score, return top N globally. Match Rust's pattern:
|
||||||
|
top_k=6 per corpus, top 8 globally before relevance filter.
|
||||||
|
|
||||||
|
3. **Relevance filter** (`internal/matrix/relevance.go`) — port the
|
||||||
|
threshold-based filter from `mcp-server/observer.ts:/relevance`.
|
||||||
|
Drops adjacency-pollution chunks that share a corpus with the hit
|
||||||
|
but aren't actually about the query. `LH_RELEVANCE_FILTER` /
|
||||||
|
`LH_RELEVANCE_THRESHOLD` env knobs preserved.
|
||||||
|
|
||||||
|
4. **Strong-model downgrade gate** (`internal/matrix/downgrade.go`) —
|
||||||
|
port `is_weak_model` + the `codereview_lakehouse → codereview_isolation`
|
||||||
|
flip from `mode.rs::execute`. Pass5 proved composed corpora lose
|
||||||
|
5/5 vs isolation on grok-4.1-fast (p=0.031); the gate is
|
||||||
|
load-bearing for paid-model retrieval quality.
|
||||||
|
|
||||||
|
5. **Learning-loop integration** — write outcomes back to a
|
||||||
|
playbook-memory corpus (probably `lakehouse_answers_v1` analogue).
|
||||||
|
This is what makes the matrix INDEX a learning system rather than
|
||||||
|
static retrieval. Per `feedback_meta_index_vision.md`: this is the
|
||||||
|
north star, not the data structure.
|
||||||
|
|
||||||
|
**Gateway routes:** `/v1/matrix/search` (multi-corpus retrieve+merge),
|
||||||
|
`/v1/matrix/corpora` (list + metadata), `/v1/matrix/relevance` (filter
|
||||||
|
endpoint, used by both internal callers and external tooling).
|
||||||
|
|
||||||
|
**Acceptance gates:**
|
||||||
|
- G3.4.A — `/v1/matrix/search` against ≥3 corpora returns merged top-N
|
||||||
|
with corpus attribution per result.
|
||||||
|
- G3.4.B — Relevance filter drops at least the threshold-margin chunks
|
||||||
|
on a known adjacency-pollution test case.
|
||||||
|
- G3.4.C — Strong-model downgrade gate flips composed→isolation when
|
||||||
|
the model is non-weak; bypassed when caller sets `force_mode`.
|
||||||
|
- G3.4.D — Concurrent Adds across N=4 corpora parallelize (no shared
|
||||||
|
write-lock); Add throughput scales near-linearly with corpus count.
|
||||||
|
|
||||||
|
**Persistence:** each corpus's vectord index persists via the existing
|
||||||
|
G1P LHV1 format. The matrix layer is stateless above that — corpus
|
||||||
|
list lives in catalog, retrieval params in config.
|
||||||
|
|
||||||
|
**Why this is its own §3.x:** in Rust the matrix indexer was emergent
|
||||||
|
and got reduced to "we have vectord" in earlier port-planning. The
|
||||||
|
SPEC names it explicitly so the port preserves the multi-corpus
|
||||||
|
retrieval shape AND the learning loop, not just the HNSW substrate.
|
||||||
|
|
||||||
|
### §3.5 — Drift quantification (loop 5 of the PRD)
|
||||||
|
|
||||||
|
**What it is.** PRD names "drift" as the 5th loop: quantify when
|
||||||
|
historical decisions stop matching current reality. Distinct from
|
||||||
|
the rating+distillation loop because drift is MEASUREMENT, not
|
||||||
|
LEARNING. The learning loop says "this match worked, remember it";
|
||||||
|
the drift loop says "this 4-month-old playbook entry — does it
|
||||||
|
still match what the substrate would surface today?"
|
||||||
|
|
||||||
|
**What's shipped (commit `be65f85`):**
|
||||||
|
- SCORER drift: re-runs current `distillation.ScoreRecord` over
|
||||||
|
historical (EvidenceRecord, persisted_category) pairs and
|
||||||
|
reports mismatches + a sorted shift matrix
|
||||||
|
- `internal/drift/drift.go` — pure-function `ComputeScorerDrift`
|
||||||
|
- 6 unit tests covering no-drift, shift detection, multi-shift
|
||||||
|
sorted-by-count, includeEntries flag, empty input, scorer-version
|
||||||
|
stamping
|
||||||
|
|
||||||
|
**Future drift shapes (not shipped):**
|
||||||
|
- PLAYBOOK drift: re-run playbook queries through current
|
||||||
|
matrix-search; recorded answer not in top-K = drift
|
||||||
|
- EMBEDDING drift: KS-test on vector distribution at T1 vs T2
|
||||||
|
- AUDIT BASELINE drift: matches Rust `audit_baselines.jsonl`
|
||||||
|
longitudinal signal
|
||||||
|
|
||||||
|
**Acceptance gates:**
|
||||||
|
- G3.5.A — A scorer-version bump triggers a non-zero `Drifted` count
|
||||||
|
on a corpus of historical ScoredRuns where the new logic produces
|
||||||
|
different categories than the persisted ones.
|
||||||
|
- G3.5.B — `ScorerDriftReport.ShiftMatrix` is deterministic-ordered
|
||||||
|
(count desc, ties broken alphabetically) so JSON output is stable
|
||||||
|
across runs.
|
||||||
|
|
||||||
|
### §3.6 — Staffing-side structured filter
|
||||||
|
|
||||||
|
**What it is.** Reality tests on the candidates + workers corpora
|
||||||
|
(commits `0d1553c`, `a97881d`) surfaced that pure semantic retrieval
|
||||||
|
can't gate by location/status/availability — the matrix indexer
|
||||||
|
returns Production Workers for a Forklift+OSHA-30 query because
|
||||||
|
nomic-embed-text's geometry doesn't separate the role labels well.
|
||||||
|
Structured filtering is the addressable piece: pre-filter the
|
||||||
|
candidate set on metadata fields BEFORE semantic ranking.
|
||||||
|
|
||||||
|
**What's shipped (commit `b199093`):**
|
||||||
|
- `SearchRequest.MetadataFilter` — `map[string]any` of metadata
|
||||||
|
field → expected value (single value or list-of-values for OR
|
||||||
|
semantics within a key, AND across keys)
|
||||||
|
- Post-retrieval filter applied before top-K truncation in
|
||||||
|
`internal/matrix/retrieve.go`
|
||||||
|
- `SearchResponse.MetadataFilterDropped` for telemetry on filter
|
||||||
|
aggressiveness
|
||||||
|
- 7 unit tests covering nil filter, missing metadata, exact match,
|
||||||
|
AND across keys, OR within list, bool match, malformed JSON
|
||||||
|
|
||||||
|
**Deferred:**
|
||||||
|
- Pre-retrieval SQL gate via `queryd` (the actual hybrid). The
|
||||||
|
post-retrieval filter is an MVP that helps when the candidate
|
||||||
|
set is mostly relevant; for aggressive filters that drop most
|
||||||
|
results, a SQL pre-filter into matrix retrieval would surface
|
||||||
|
the right candidates with less wasted embedding work.
|
||||||
|
- Filter language richer than equality (e.g. range, prefix, regex).
|
||||||
|
|
||||||
|
**Acceptance gates:**
|
||||||
|
- G3.6.A — `MetadataFilter: {"state": "IL"}` against a mixed-state
|
||||||
|
corpus drops every non-IL result; `MetadataFilterDropped` reports
|
||||||
|
the count.
|
||||||
|
- G3.6.B — List filter `{"state": ["IL", "WI"]}` keeps both states,
|
||||||
|
drops the rest (OR within key).
|
||||||
|
- G3.6.C — Multi-key filter is AND: a result missing any key is
|
||||||
|
dropped, no exception.
|
||||||
|
|
||||||
|
### §3.7 — Operational rating wiring
|
||||||
|
|
||||||
|
**What it is.** PRD loop 4 (rating + distillation) needs real
|
||||||
|
inflows to be a learning system rather than a substrate. The
|
||||||
|
playbook-record endpoint (`06e7152`) takes one (query, answer,
|
||||||
|
score) per call; productizing it into actual signal sources is what
|
||||||
|
makes the system get smarter with use.
|
||||||
|
|
||||||
|
**What's shipped (commit `6392772`):**
|
||||||
|
- `POST /v1/matrix/playbooks/bulk` — bulk-record N successes;
|
||||||
|
per-entry success/failure response so callers can see which of
|
||||||
|
a 4,701-row historical placement import succeeded vs which
|
||||||
|
failed validation.
|
||||||
|
- Single-record path from `06e7152` unchanged.
|
||||||
|
|
||||||
|
**Deferred:**
|
||||||
|
- UI shim for click-tracking (no Go demo UI yet — the Bun demo at
|
||||||
|
`devop.live/lakehouse/` is still serving the public surface).
|
||||||
|
When the Go UI lands or a feedback API is added to the Bun UI,
|
||||||
|
every coordinator click → bulk-batched POST → playbook entry.
|
||||||
|
- Negative feedback (this match didn't work). Currently only
|
||||||
|
positive scores are recorded; a rejection signal would help the
|
||||||
|
learning loop avoid pushing bad matches.
|
||||||
|
- Time-decay on playbook scores so stale recommendations attenuate.
|
||||||
|
|
||||||
|
**Acceptance gates:**
|
||||||
|
- G3.7.A — Bulk POST of N entries returns `{recorded, failed,
|
||||||
|
results[]}` with per-entry IDs/errors, no single-entry failure
|
||||||
|
aborting the batch.
|
||||||
|
- G3.7.B — Each recorded entry surfaces in `/v1/matrix/search` with
|
||||||
|
`use_playbook=true` after a re-query.
|
||||||
|
|
||||||
|
### §3.8 — Observer-KB workflow runner (Archon-style multi-pass)
|
||||||
|
|
||||||
|
**What it is.** The architectural pattern documented in the Rust
|
||||||
|
`observer-kb` branch (10 commits ahead of main, never merged) and
|
||||||
|
proven by `/home/profit/external/Archon`'s workflow engine. Multiple
|
||||||
|
mode passes processing data, with each pass an objective measurement
|
||||||
|
that contributes to the KB:
|
||||||
|
|
||||||
|
```
|
||||||
|
Raw data
|
||||||
|
↓ Mode: EXTRACT structured facts/entities/relationships
|
||||||
|
↓ Mode: VALIDATOR fact-check, confidence 1-10
|
||||||
|
↓ Mode: HALLUCINATION verify each claim, flag likely fabrications
|
||||||
|
↓ Mode: CONSENSUS multiple passes until extraction converges
|
||||||
|
↓ Mode: REDTEAM attack what survived, patch what fails
|
||||||
|
↓ Mode: PIPELINE clean → Q&A structure → topic group → rank
|
||||||
|
↓ RENDER curated doc anchored on questions
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the *orchestrator* missing from §3.4 components 1-5: each
|
||||||
|
SPEC §3.4 piece (relevance, downgrade, scorer, drift) is a "mode";
|
||||||
|
what's missing is the workflow engine that chains them.
|
||||||
|
|
||||||
|
**Why it matters.** Per the PRD's product vision: the observer
|
||||||
|
should make actionable decisions based on watching what's
|
||||||
|
successful. The workflow runner is how observers compose modes
|
||||||
|
into multi-pass pipelines that score outcomes rigorously enough
|
||||||
|
to feed the KB and inform the playbook substrate.
|
||||||
|
|
||||||
|
**Reference materials on the system:**
|
||||||
|
- `/home/profit/lakehouse/.archon/workflows/lakehouse-architect-review.yaml`
|
||||||
|
(committed `69919d9` in main) — proves Archon-via-Lakehouse
|
||||||
|
works with a 3-node `shape → weakness → improvement` workflow
|
||||||
|
- `/home/profit/external/Archon` — the upstream workflow engine
|
||||||
|
(cloned 2026-04-26); `packages/providers/src/community/pi/provider.ts`
|
||||||
|
has the local Lakehouse-routing mod committed locally as
|
||||||
|
`3f2afc8` (not pushed to upstream `coleam00/Archon`)
|
||||||
|
- Rust `observer-kb` branch (10 commits, +4338/-55506 LoC) —
|
||||||
|
`apps/observer-kb/docs/PRD.md` documents the multi-pass
|
||||||
|
architecture; `scripts/{deep_analysis,extract_knowledge,process_knowledge}.py`
|
||||||
|
are the Python prototypes that proved it on real ChatGPT/Claude
|
||||||
|
PDF data (496 topics, 300 decisions, 100 insights extracted)
|
||||||
|
|
||||||
|
**Components to port (in dependency order):**
|
||||||
|
|
||||||
|
1. **Workflow definition** (`internal/workflow/types.go`) — YAML
|
||||||
|
schema matching Archon's shape: `name`, `description`, `provider`,
|
||||||
|
`model`, list of `nodes` each with `id`, `prompt`, `allowed_tools`,
|
||||||
|
`effort`, `idle_timeout`, `depends_on`. The depends_on edges form
|
||||||
|
a DAG; the runner resolves topologically.
|
||||||
|
|
||||||
|
2. **Node executor** (`internal/workflow/runner.go`) — given a
|
||||||
|
workflow and a starting context, walks the DAG, executes each
|
||||||
|
node by dispatching to the configured backend (matrix.Search,
|
||||||
|
distillation.ScoreRecord, drift.ComputeScorerDrift, or a generic
|
||||||
|
prompt-against-LLM via gateway `/v1/chat`), captures per-node
|
||||||
|
output, makes it available as `$<node_id>.output` in subsequent
|
||||||
|
nodes.
|
||||||
|
|
||||||
|
3. **Provenance recording** — every node execution lands an
|
||||||
|
ObservedOp (via the observerd substrate from `bc9ab93`) with
|
||||||
|
`source: "workflow"`, the workflow name + node ID, input/output
|
||||||
|
summaries, and timing. The ring buffer + JSONL log become the
|
||||||
|
substrate for the rating+distillation loop's KB feed.
|
||||||
|
|
||||||
|
4. **Mode catalog** (`internal/workflow/modes.go`) — registry of
|
||||||
|
the modes the runner can dispatch to. Each mode is a Go function
|
||||||
|
matching a uniform `func(ctx, input map[string]any) (map[string]any, error)`
|
||||||
|
signature so workflows can compose them. Initial modes from
|
||||||
|
§3.4: `matrix.search`, `matrix.relevance`, `matrix.downgrade`,
|
||||||
|
`playbook.record`, `playbook.lookup`, `distillation.score`,
|
||||||
|
`drift.scorer`. Plus `llm.chat` for free-form mode prompts.
|
||||||
|
|
||||||
|
5. **HTTP surface** — `POST /v1/observer/workflow/run` accepts a
|
||||||
|
workflow YAML body + a starting context; returns the per-node
|
||||||
|
results + the chain of ObservedOps generated. `GET
|
||||||
|
/v1/observer/workflow/list` lists workflows in a known directory
|
||||||
|
for operator discoverability.
|
||||||
|
|
||||||
|
**Why integrate into observerd, not a new service.** The observer
|
||||||
|
is the system resource that watches and records. Workflows ARE
|
||||||
|
observation patterns — multi-step processes whose every step is
|
||||||
|
recorded. Putting the runner inside observerd keeps the
|
||||||
|
"measurement → KB feed" wiring tight; a separate service would
|
||||||
|
re-implement the recording layer.
|
||||||
|
|
||||||
|
**Acceptance gates:**
|
||||||
|
- G3.8.A — Load a workflow YAML matching the Archon `lakehouse-architect-review.yaml`
|
||||||
|
shape; runner executes the 3-node DAG topologically.
|
||||||
|
- G3.8.B — Each node execution lands an ObservedOp with
|
||||||
|
`source: "workflow"` and the node's input/output. Stats endpoint
|
||||||
|
shows the workflow ops.
|
||||||
|
- G3.8.C — A node referencing `$<prior_node>.output` in its prompt
|
||||||
|
resolves correctly; missing reference is a clear error not a
|
||||||
|
silent empty string.
|
||||||
|
- G3.8.D — Mode catalog dispatches `matrix.search` invocation to
|
||||||
|
the matrixd backend without going through HTTP (in-process
|
||||||
|
function call when matrixd is co-resident).
|
||||||
|
|
||||||
|
**Status:** PORT TARGET, not yet started. SPEC commits the design;
|
||||||
|
implementation is its own wave (estimated **L** effort given the
|
||||||
|
DAG runner + mode dispatch + provenance recording).
|
||||||
|
|
||||||
|
### §3.9 — chatd (multi-provider LLM dispatcher) — SHIPPED 2026-04-30
|
||||||
|
|
||||||
|
**Status:** done at commit `05273ac` (Phase 4 wave) + scrum-hardened
|
||||||
|
at `0efc736`. Composite port: 1,624 LoC, 19+ tests, 6/6 chatd_smoke.
|
||||||
|
|
||||||
|
**What:** `cmd/chatd` on `:3220` routes `POST /chat` to a provider
|
||||||
|
selected by model-name prefix or `:cloud` suffix:
|
||||||
|
|
||||||
|
```
|
||||||
|
ollama/<m> → local Ollama at :11434 (no auth)
|
||||||
|
ollama_cloud/<m> → ollama.com /api/generate (Bearer)
|
||||||
|
<m>:cloud → ollama_cloud (suffix variant)
|
||||||
|
openrouter/<v>/<m> → openrouter.ai (OpenAI-compat, Bearer)
|
||||||
|
opencode/<m> → opencode.ai/zen/v1 (OpenAI-compat, Bearer)
|
||||||
|
kimi/<m> → api.kimi.com/coding/v1 (OpenAI-compat, Bearer)
|
||||||
|
bare names → ollama (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Provider key resolution:** env var first (`OPENROUTER_API_KEY`,
|
||||||
|
`OPENCODE_API_KEY`, `KIMI_API_KEY`, `OLLAMA_CLOUD_KEY`); then
|
||||||
|
`/etc/lakehouse/<provider>.env` fallback (mode 0600). Empty key →
|
||||||
|
provider stays unregistered (404 at first call instead of 503).
|
||||||
|
|
||||||
|
**Companion to the model tier registry** in `lakehouse.toml [models]`
|
||||||
|
(local_fast / local_judge / cloud_judge / frontier_review / etc.),
|
||||||
|
which maps tier names to model IDs. Callers reference
|
||||||
|
`cfg.Models.LocalJudge` instead of literal strings. Bumping a tier
|
||||||
|
is a 1-line config edit.
|
||||||
|
|
||||||
|
**Quirks captured by today's scrum:**
|
||||||
|
- `Request.Temperature` is `*float64` (pointer) — Anthropic 4.7 (via
|
||||||
|
OpenCode) rejects the field entirely with "temperature is deprecated."
|
||||||
|
Pointer lets us omit when caller didn't set explicitly.
|
||||||
|
- Local Ollama defaults `think=false` — qwen3.5:latest is reasoning-
|
||||||
|
capable but the inner-loop hot path wants direct answers, not
|
||||||
|
reasoning traces consuming the token budget.
|
||||||
|
|
||||||
|
**Replaces the Rust adapters:**
|
||||||
|
- `crates/gateway/src/v1/ollama_cloud.rs` → `internal/chat/ollama_cloud.go`
|
||||||
|
- `crates/gateway/src/v1/openrouter.rs` → `internal/chat/openai_compat.go` (shared with opencode + kimi)
|
||||||
|
- `crates/gateway/src/v1/opencode.rs` → same shared helper
|
||||||
|
- `crates/aibridge/*` → `internal/chat/` (cleaner abstraction)
|
||||||
|
|
||||||
|
**Reusable downstream:** `scripts/scrum_review.sh` runs a 3-lineage
|
||||||
|
cross-review (Opus + Kimi + Qwen3-coder) by POST-ing each diff to
|
||||||
|
chatd's `/v1/chat`. Same vehicle the harness's own scrum-hardening
|
||||||
|
used to find its own bugs.
|
||||||
|
|
||||||
|
### §3.10 — local-review-harness (sibling tool, separate repo)
|
||||||
|
|
||||||
|
**Where:** `git.agentview.dev/profit/local-review-harness` (also
|
||||||
|
SMB-mounted at `/home/profit/share/local-review-harness-full-md/`).
|
||||||
|
|
||||||
|
**What:** a local-first code review harness — walks a target repo,
|
||||||
|
runs evidence-bearing static checks (12 analyzers covering hardcoded
|
||||||
|
paths, raw SQL, wildcard CORS, secrets, exec/spawn, large files,
|
||||||
|
TODO/FIXME, missing tests, .env files committed, exposed mutation
|
||||||
|
endpoints, hardcoded private IPs), produces Scrum-style markdown
|
||||||
|
reports + JSON receipts. **No cloud deps.** Single static Go binary.
|
||||||
|
|
||||||
|
**Status:** Phase A (skeleton) + Phase B (MVP — static-only path)
|
||||||
|
shipped at first commit `f3ee472`. 5 acceptance gates green plus
|
||||||
|
self-review (the harness reviews its own repo).
|
||||||
|
|
||||||
|
**Phases C–E pending:** local-Ollama LLM review, validation cross-
|
||||||
|
check, append-only `.memory/`, diff/rules subcommands.
|
||||||
|
|
||||||
|
**Why a sibling tool, not a Lakehouse module:** PROMPT.md "Strategic
|
||||||
|
Goal" — the harness eventually plugs into OpenClaw / MCP tools /
|
||||||
|
Lakehouse memory / playbook sealing / observer review loop. But
|
||||||
|
first it has to be reliable, inspectable, and evidence-driven on
|
||||||
|
its own. Lakehouse-Go integration is post-Phase-E (probably E+1:
|
||||||
|
write the harness's findings into Lakehouse's playbook substrate
|
||||||
|
via `/v1/matrix/playbooks/record`).
|
||||||
|
|
||||||
|
**Cross-pollination opportunities (post-MVP):**
|
||||||
|
- Replace `internal/llm/ollama.go` in the harness with a thin client
|
||||||
|
pointed at chatd's `/v1/chat` — frontier judges become a config
|
||||||
|
toggle.
|
||||||
|
- Feed harness findings into Lakehouse-Go's pathway memory as a
|
||||||
|
drift signal (which static checks fired this run vs last).
|
||||||
|
- Use the harness's `.memory/known-risks.json` as a corpus the
|
||||||
|
matrix indexer can retrieve from when the same risk pattern appears.
|
||||||
|
|
||||||
### §3.3 — UI (HTMX)
|
### §3.3 — UI (HTMX)
|
||||||
|
|
||||||
**Approach:** server-rendered Go templates using `html/template`,
|
**Approach:** server-rendered Go templates using `html/template`,
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.16
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.16
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0
|
||||||
github.com/aws/smithy-go v1.25.0
|
github.com/aws/smithy-go v1.25.0
|
||||||
|
github.com/coder/hnsw v0.6.1
|
||||||
|
github.com/duckdb/duckdb-go/v2 v2.10502.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0
|
github.com/pelletier/go-toml/v2 v2.3.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,26 +37,29 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/chewxy/math32 v1.10.1 // indirect
|
github.com/chewxy/math32 v1.10.1 // indirect
|
||||||
github.com/coder/hnsw v0.6.1 // indirect
|
|
||||||
github.com/duckdb/duckdb-go-bindings v0.10502.0 // indirect
|
github.com/duckdb/duckdb-go-bindings v0.10502.0 // indirect
|
||||||
github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10502.0 // indirect
|
github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10502.0 // indirect
|
||||||
github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10502.0 // indirect
|
github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10502.0 // indirect
|
||||||
github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10502.0 // indirect
|
github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10502.0 // indirect
|
||||||
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10502.0 // indirect
|
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10502.0 // indirect
|
||||||
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10502.0 // indirect
|
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10502.0 // indirect
|
||||||
github.com/duckdb/duckdb-go/v2 v2.10502.0 // indirect
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.6 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
github.com/google/renameio v1.0.1 // indirect
|
github.com/google/renameio v1.0.1 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/viterin/partial v1.1.0 // indirect
|
github.com/viterin/partial v1.1.0 // indirect
|
||||||
github.com/viterin/vek v0.4.2 // indirect
|
github.com/viterin/vek v0.4.2 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
|||||||
@@ -74,26 +74,38 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
|||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
|
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
|
||||||
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU=
|
github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU=
|
||||||
github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk=
|
github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
@@ -104,6 +116,8 @@ github.com/viterin/vek v0.4.2 h1:Vyv04UjQT6gcjEFX82AS9ocgNbAJqsHviheIBdPlv5U=
|
|||||||
github.com/viterin/vek v0.4.2/go.mod h1:A4JRAe8OvbhdzBL5ofzjBS0J29FyUrf95tQogvtHHUc=
|
github.com/viterin/vek v0.4.2/go.mod h1:A4JRAe8OvbhdzBL5ofzjBS0J29FyUrf95tQogvtHHUc=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
@@ -124,12 +138,16 @@ golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7
|
|||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
// Subject manifests + per-subject audit-log chain verification.
|
||||||
|
//
|
||||||
|
// Specification: /home/profit/lakehouse/docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md.
|
||||||
|
//
|
||||||
|
// This file is the Go side of Step 8 (cross-runtime parity). The Rust
|
||||||
|
// side is the writer (crates/catalogd/src/subject_audit.rs); Go is a
|
||||||
|
// READER + VERIFIER only — both sides serialize / hash with identical
|
||||||
|
// algorithms so a chain written by Rust verifies under Go.
|
||||||
|
//
|
||||||
|
// Algorithm (must match Rust crates/catalogd/src/subject_audit.rs exactly):
|
||||||
|
// row_hmac = HMAC-SHA256(key, prev_chain_hash_bytes || canonical_row_bytes)
|
||||||
|
//
|
||||||
|
// where:
|
||||||
|
// - prev_chain_hash_bytes is the ASCII bytes of the previous row's
|
||||||
|
// row_hmac field, OR the literal ASCII string "GENESIS" for the
|
||||||
|
// first row (no hex decode!)
|
||||||
|
// - canonical_row_bytes is the row JSON with the row_hmac field
|
||||||
|
// dropped, with all object keys sorted alphabetically at every
|
||||||
|
// nesting depth, with no insignificant whitespace
|
||||||
|
// - the final hash is rendered as 64 lowercase hex characters
|
||||||
|
package catalogd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const GenesisHash = "GENESIS"
|
||||||
|
|
||||||
|
// SubjectManifest mirrors crates/shared/src/types.rs::SubjectManifest.
|
||||||
|
// Keep field tags byte-identical so manifest JSON written by Rust round-trips.
|
||||||
|
//
|
||||||
|
// Vertical: omitempty matches Rust's serde-default deserializer accepting
|
||||||
|
// missing field (default Unknown). For the OTHER fields we deliberately
|
||||||
|
// avoid omitempty since Rust always emits them.
|
||||||
|
type SubjectManifest struct {
|
||||||
|
Schema string `json:"schema"`
|
||||||
|
CandidateID string `json:"candidate_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Vertical string `json:"vertical,omitempty"`
|
||||||
|
Consent SubjectConsent `json:"consent"`
|
||||||
|
Retention SubjectRetention `json:"retention"`
|
||||||
|
Datasets []SubjectDatasetRef `json:"datasets"`
|
||||||
|
SafeViews []string `json:"safe_views"`
|
||||||
|
AuditLogPath string `json:"audit_log_path"`
|
||||||
|
AuditLogChainRoot string `json:"audit_log_chain_root"`
|
||||||
|
BiometricCollection *BiometricCollection `json:"biometric_collection,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BiometricCollection mirrors crates/shared/src/types.rs::BiometricCollection.
|
||||||
|
// Written by the Phase 1.6 Gate 3 photo-upload endpoint. None until the
|
||||||
|
// first biometric upload; cleared on BIPA erasure.
|
||||||
|
type BiometricCollection struct {
|
||||||
|
DataPath string `json:"data_path"`
|
||||||
|
TemplateHash string `json:"template_hash"`
|
||||||
|
CollectedAt time.Time `json:"collected_at"`
|
||||||
|
ConsentVersionHash string `json:"consent_version_hash,omitempty"`
|
||||||
|
Classifications json.RawMessage `json:"classifications,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubjectConsent struct {
|
||||||
|
GeneralPii GeneralPiiConsent `json:"general_pii"`
|
||||||
|
Biometric BiometricConsent `json:"biometric"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeneralPiiConsent struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
GivenAt *time.Time `json:"given_at,omitempty"`
|
||||||
|
WithdrawnAt *time.Time `json:"withdrawn_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BiometricConsent struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
RetentionUntil *time.Time `json:"retention_until,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubjectRetention struct {
|
||||||
|
GeneralPiiUntil time.Time `json:"general_pii_until"`
|
||||||
|
Policy string `json:"policy,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubjectDatasetRef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
KeyColumn string `json:"key_column"`
|
||||||
|
KeyValue string `json:"key_value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubjectAuditRow mirrors crates/shared/src/types.rs::SubjectAuditRow.
|
||||||
|
// JSON field order does NOT matter (canonicalizer sorts at hash time)
|
||||||
|
// but field names + types MUST match for round-trip.
|
||||||
|
type SubjectAuditRow struct {
|
||||||
|
Schema string `json:"schema"`
|
||||||
|
Ts time.Time `json:"ts"`
|
||||||
|
CandidateID string `json:"candidate_id"`
|
||||||
|
Accessor AuditAccessor `json:"accessor"`
|
||||||
|
FieldsAccessed []string `json:"fields_accessed"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
PrevChainHash string `json:"prev_chain_hash"`
|
||||||
|
RowHmac string `json:"row_hmac"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditAccessor mirrors crates/shared/src/types.rs::AuditAccessor.
|
||||||
|
//
|
||||||
|
// IMPORTANT: trace_id has NO omitempty — Rust's serde always writes
|
||||||
|
// the field (even empty) because `#[serde(default)]` only affects
|
||||||
|
// READS. Stripping it here produces different canonical bytes than
|
||||||
|
// the writer used → HMAC mismatch on real production rows. (Caught
|
||||||
|
// 2026-05-03 by subject_audit_parity.sh on a live WORKER-1 row with
|
||||||
|
// trace_id="".)
|
||||||
|
type AuditAccessor struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Daemon string `json:"daemon"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
TraceID string `json:"trace_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSubjectManifest reads + parses a subject manifest JSON file.
|
||||||
|
func LoadSubjectManifest(path string) (*SubjectManifest, error) {
|
||||||
|
bytes, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read manifest %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var m SubjectManifest
|
||||||
|
if err := json.Unmarshal(bytes, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse manifest %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLogEntry is one parsed audit row PLUS the raw line bytes from
|
||||||
|
// the file. The raw bytes are load-bearing: we MUST canonicalize from
|
||||||
|
// them (not from the re-marshaled struct) to avoid time-precision drift.
|
||||||
|
//
|
||||||
|
// Why: Rust's chrono RFC3339-AutoSi serializes nanoseconds with 9 digits
|
||||||
|
// (e.g. "461439210"). Go's time.RFC3339Nano strips trailing zeros (e.g.
|
||||||
|
// "46143921"). Round-tripping through time.Time changes the byte sequence
|
||||||
|
// and breaks the HMAC. Read once, hash from the original bytes.
|
||||||
|
// (Caught 2026-05-03 by subject_audit_parity.sh on a real WORKER-5 row
|
||||||
|
// whose nanoseconds happened to end in 0.)
|
||||||
|
type AuditLogEntry struct {
|
||||||
|
Row SubjectAuditRow
|
||||||
|
Raw []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAuditLog parses an audit JSONL file. Returns one AuditLogEntry
|
||||||
|
// per non-empty line. Defensive: blank lines are skipped, unparseable
|
||||||
|
// lines are skipped (matches Rust read_rows_in_range).
|
||||||
|
func ReadAuditLog(path string) ([]AuditLogEntry, error) {
|
||||||
|
bytes, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read audit log %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var entries []AuditLogEntry
|
||||||
|
for _, line := range strings.Split(string(bytes), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var row SubjectAuditRow
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &row); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, AuditLogEntry{Row: row, Raw: []byte(trimmed)})
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalRowBytesFromRaw is the production canonicalizer used by
|
||||||
|
// VerifyChain. It accepts the raw line bytes from the audit JSONL,
|
||||||
|
// drops the row_hmac key, sorts all keys alphabetically at every
|
||||||
|
// nesting depth, and re-emits compact JSON. Numbers + strings pass
|
||||||
|
// through verbatim (json.Number preserves number text, strings are
|
||||||
|
// re-JSON-escaped identically to Rust's serde_json).
|
||||||
|
//
|
||||||
|
// CRITICAL: hashing must always go through this function. NEVER hash
|
||||||
|
// the bytes produced by canonicalRowBytesFromStruct against a real
|
||||||
|
// production chain — the time-precision drift will give wrong HMACs
|
||||||
|
// for any row whose nanoseconds end in 0.
|
||||||
|
func canonicalRowBytesFromRaw(rawLine []byte) ([]byte, error) {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(rawLine)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var v any
|
||||||
|
if err := dec.Decode(&v); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode raw line: %w", err)
|
||||||
|
}
|
||||||
|
if obj, ok := v.(map[string]any); ok {
|
||||||
|
delete(obj, "row_hmac")
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := writeCanonical(&buf, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []byte(buf.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalRowBytesFromStruct is for the parity probe's known-answer
|
||||||
|
// vector + tests where we WANT to canonicalize a Go-built row from
|
||||||
|
// scratch (no original bytes exist yet). Production verification uses
|
||||||
|
// canonicalRowBytesFromRaw against the file bytes; this function is
|
||||||
|
// only safe for synthetic inputs whose ts has no trailing-zero nanos.
|
||||||
|
func canonicalRowBytesFromStruct(row *SubjectAuditRow) ([]byte, error) {
|
||||||
|
raw, err := json.Marshal(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal row: %w", err)
|
||||||
|
}
|
||||||
|
return canonicalRowBytesFromRaw(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalNoEscapeHTML wraps json.Encoder with HTML escaping disabled.
|
||||||
|
//
|
||||||
|
// Why: Go's json.Marshal escapes `<`, `>`, `&` to `<`, `>`,
|
||||||
|
// `&` by default. Rust's serde_json::to_vec keeps them literal.
|
||||||
|
// Any string field containing one of those characters would produce
|
||||||
|
// different canonical bytes across runtimes → broken HMAC chain.
|
||||||
|
// (Caught 2026-05-03 by opus scrum WARN on parity_subject_audit.rs:
|
||||||
|
// canonical_json — initially undetected because no production audit
|
||||||
|
// field contained `<>&`, but realistic for purpose strings like
|
||||||
|
// "error & retry" or trace_id "<HTTP-Request-Id>".)
|
||||||
|
//
|
||||||
|
// Also strips the trailing newline json.Encoder appends — that newline
|
||||||
|
// is meaningful to JSONL consumers but is junk for hash input.
|
||||||
|
func marshalNoEscapeHTML(v any) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := buf.Bytes()
|
||||||
|
return bytes.TrimRight(out, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCanonical recursively writes v as canonical JSON: object keys
|
||||||
|
// sorted alphabetically, no insignificant whitespace. Arrays preserve
|
||||||
|
// element order (semantically significant per spec §3).
|
||||||
|
//
|
||||||
|
// All scalar emission goes through marshalNoEscapeHTML so the byte
|
||||||
|
// sequence matches Rust's serde_json output character-for-character.
|
||||||
|
func writeCanonical(buf *strings.Builder, v any) error {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
keys := make([]string, 0, len(t))
|
||||||
|
for k := range t {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
buf.WriteByte('{')
|
||||||
|
for i, k := range keys {
|
||||||
|
if i > 0 {
|
||||||
|
buf.WriteByte(',')
|
||||||
|
}
|
||||||
|
ks, err := marshalNoEscapeHTML(k)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal key: %w", err)
|
||||||
|
}
|
||||||
|
buf.Write(ks)
|
||||||
|
buf.WriteByte(':')
|
||||||
|
if err := writeCanonical(buf, t[k]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.WriteByte('}')
|
||||||
|
case []any:
|
||||||
|
buf.WriteByte('[')
|
||||||
|
for i, elem := range t {
|
||||||
|
if i > 0 {
|
||||||
|
buf.WriteByte(',')
|
||||||
|
}
|
||||||
|
if err := writeCanonical(buf, elem); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.WriteByte(']')
|
||||||
|
default:
|
||||||
|
bs, err := marshalNoEscapeHTML(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal scalar: %w", err)
|
||||||
|
}
|
||||||
|
buf.Write(bs)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanonicalAndHmac is a helper for the parity probe: canonicalizes the
|
||||||
|
// row from scratch (struct → JSON → canonical), computes the HMAC
|
||||||
|
// against the given prev_hash, returns both. Used only for the
|
||||||
|
// known-answer fixture; production verification uses VerifyChain
|
||||||
|
// (which canonicalizes from the original file bytes to dodge time
|
||||||
|
// precision drift).
|
||||||
|
func CanonicalAndHmac(row *SubjectAuditRow, key []byte, prevHash string) (canonical []byte, hmacHex string, err error) {
|
||||||
|
canonical, err = canonicalRowBytesFromStruct(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
hmacHex = computeRowHMAC(key, prevHash, canonical)
|
||||||
|
return canonical, hmacHex, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeRowHMAC = HMAC-SHA256(key, prev_hash_ascii_bytes || canonical_row_bytes)
|
||||||
|
// rendered as 64-char lowercase hex.
|
||||||
|
func computeRowHMAC(key []byte, prevHash string, canonical []byte) string {
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
mac.Write([]byte(prevHash))
|
||||||
|
mac.Write(canonical)
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyChain replays the chain for a subject's audit log. Returns the
|
||||||
|
// number of rows verified, the chain tip (last row's row_hmac, or
|
||||||
|
// GENESIS when log is empty), and an error describing the first chain
|
||||||
|
// break found.
|
||||||
|
//
|
||||||
|
// CRITICAL: canonicalizes from each entry's RAW LINE BYTES, not from
|
||||||
|
// the parsed struct. Round-tripping through encoding/json + time.Time
|
||||||
|
// strips trailing zeros from RFC3339 nanos and produces different
|
||||||
|
// canonical bytes than the Rust writer used (which used chrono's
|
||||||
|
// AutoSi 9-digit format). Catches: 2026-05-03 WORKER-5 nanos
|
||||||
|
// 461439210 → Rust emits 9 digits, Go's time.RFC3339Nano emits 8.
|
||||||
|
//
|
||||||
|
// Special case: empty/missing log returns (0, GENESIS, nil).
|
||||||
|
func VerifyChain(entries []AuditLogEntry, key []byte) (count int, chainTip string, err error) {
|
||||||
|
prev := GenesisHash
|
||||||
|
for i, entry := range entries {
|
||||||
|
if entry.Row.PrevChainHash != prev {
|
||||||
|
return i, prev, fmt.Errorf("chain break at row %d: prev_chain_hash=%q expected=%q",
|
||||||
|
i+1, entry.Row.PrevChainHash, prev)
|
||||||
|
}
|
||||||
|
claimed := entry.Row.RowHmac
|
||||||
|
canonical, cerr := canonicalRowBytesFromRaw(entry.Raw)
|
||||||
|
if cerr != nil {
|
||||||
|
return i, prev, fmt.Errorf("canonicalize row %d: %w", i+1, cerr)
|
||||||
|
}
|
||||||
|
recomputed := computeRowHMAC(key, prev, canonical)
|
||||||
|
if recomputed != claimed {
|
||||||
|
return i, prev, fmt.Errorf("hmac mismatch at row %d: stored=%s recomputed=%s",
|
||||||
|
i+1, claimed, recomputed)
|
||||||
|
}
|
||||||
|
prev = claimed
|
||||||
|
count = i + 1
|
||||||
|
}
|
||||||
|
chainTip = prev
|
||||||
|
return count, chainTip, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadKeyFile reads a signing key file. Refuses keys shorter than 32
|
||||||
|
// bytes (matches Rust: 2026-05-03 kimi BLOCK fix lifted the minimum
|
||||||
|
// from 16 to 32 to align with HMAC-SHA256 best practice).
|
||||||
|
func LoadKeyFile(path string) ([]byte, error) {
|
||||||
|
key, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read key file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if len(key) < 32 {
|
||||||
|
return nil, fmt.Errorf("signing key is %d bytes; recommend ≥32 bytes for HMAC-SHA256", len(key))
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package catalogd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deterministicKey is the same fixture key the Rust tests use:
|
||||||
|
// (0u8..32).collect() — so a Rust-written chain verifies under Go.
|
||||||
|
func deterministicKey() []byte {
|
||||||
|
k := make([]byte, 32)
|
||||||
|
for i := range k {
|
||||||
|
k[i] = byte(i)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkRow(candidateID string, fields []string, prevHash, ts string) SubjectAuditRow {
|
||||||
|
t, _ := time.Parse(time.RFC3339Nano, ts)
|
||||||
|
return SubjectAuditRow{
|
||||||
|
Schema: "subject_audit.v1",
|
||||||
|
Ts: t,
|
||||||
|
CandidateID: candidateID,
|
||||||
|
Accessor: AuditAccessor{
|
||||||
|
Kind: "gateway_lookup",
|
||||||
|
Daemon: "gateway",
|
||||||
|
Purpose: "fill_validation",
|
||||||
|
TraceID: "",
|
||||||
|
},
|
||||||
|
FieldsAccessed: fields,
|
||||||
|
Result: "success",
|
||||||
|
PrevChainHash: prevHash,
|
||||||
|
RowHmac: "", // computed below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCanonicalJSON_KeysSortedAlphabetically asserts the same property
|
||||||
|
// the Rust unit test asserts (subject_audit::tests::canonical_json_sorts_keys_alphabetically).
|
||||||
|
func TestCanonicalJSON_KeysSortedAlphabetically(t *testing.T) {
|
||||||
|
v := map[string]any{
|
||||||
|
"z": 1,
|
||||||
|
"a": 2,
|
||||||
|
"m": map[string]any{"y": 1, "b": 2},
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := writeCanonical(&buf, v); err != nil {
|
||||||
|
t.Fatalf("canonical: %v", err)
|
||||||
|
}
|
||||||
|
s := buf.String()
|
||||||
|
a, m, z := strings.Index(s, "\"a\""), strings.Index(s, "\"m\""), strings.Index(s, "\"z\"")
|
||||||
|
if !(a < m && m < z) {
|
||||||
|
t.Fatalf("top-level keys out of order: %s", s)
|
||||||
|
}
|
||||||
|
b, y := strings.Index(s, "\"b\""), strings.Index(s, "\"y\"")
|
||||||
|
if !(b < y) {
|
||||||
|
t.Fatalf("nested keys out of order: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCanonicalJSON_ArraysPreserveOrder asserts arrays are NOT sorted —
|
||||||
|
// matches Rust subject_audit::tests::canonical_json_arrays_preserve_order.
|
||||||
|
func TestCanonicalJSON_ArraysPreserveOrder(t *testing.T) {
|
||||||
|
v := map[string]any{"k": []any{"c", "a", "b"}}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := writeCanonical(&buf, v); err != nil {
|
||||||
|
t.Fatalf("canonical: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "\"c\",\"a\",\"b\"") {
|
||||||
|
t.Fatalf("array order altered: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEntry produces an AuditLogEntry by computing the HMAC against
|
||||||
|
// the row's struct-derived canonical bytes, then storing the resulting
|
||||||
|
// row JSON as the raw bytes. Test-only — production reads raw bytes
|
||||||
|
// straight from disk so the time-precision drift doesn't apply.
|
||||||
|
func buildEntry(row SubjectAuditRow, key []byte, prev string) AuditLogEntry {
|
||||||
|
canon, err := canonicalRowBytesFromStruct(&row)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
row.RowHmac = computeRowHMAC(key, prev, canon)
|
||||||
|
raw, err := json.Marshal(row)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return AuditLogEntry{Row: row, Raw: raw}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyChain_ReplaysAndReachesTip writes 3 rows with HMACs computed
|
||||||
|
// the same way Rust would, then verifies they chain. This is the local
|
||||||
|
// half of the parity contract — the cross-runtime half (Rust writes,
|
||||||
|
// Go verifies) is covered by scripts/cutover/parity/subject_audit_parity.sh.
|
||||||
|
func TestVerifyChain_ReplaysAndReachesTip(t *testing.T) {
|
||||||
|
key := deterministicKey()
|
||||||
|
r1 := mkRow("CAND-PARITY", []string{"name"}, GenesisHash, "2026-05-03T12:00:00Z")
|
||||||
|
e1 := buildEntry(r1, key, GenesisHash)
|
||||||
|
r2 := mkRow("CAND-PARITY", []string{"phone"}, e1.Row.RowHmac, "2026-05-03T12:00:01Z")
|
||||||
|
e2 := buildEntry(r2, key, e1.Row.RowHmac)
|
||||||
|
r3 := mkRow("CAND-PARITY", []string{"email"}, e2.Row.RowHmac, "2026-05-03T12:00:02Z")
|
||||||
|
e3 := buildEntry(r3, key, e2.Row.RowHmac)
|
||||||
|
|
||||||
|
count, tip, err := VerifyChain([]AuditLogEntry{e1, e2, e3}, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify failed: %v", err)
|
||||||
|
}
|
||||||
|
if count != 3 {
|
||||||
|
t.Fatalf("expected 3 rows verified, got %d", count)
|
||||||
|
}
|
||||||
|
if tip != e3.Row.RowHmac {
|
||||||
|
t.Fatalf("chain tip wrong: tip=%s expected=%s", tip, e3.Row.RowHmac)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyChain_EmptyLogIsTriviallyValid mirrors Rust's empty-log
|
||||||
|
// special case: 0 rows, GENESIS tip, no error.
|
||||||
|
func TestVerifyChain_EmptyLogIsTriviallyValid(t *testing.T) {
|
||||||
|
count, tip, err := VerifyChain(nil, deterministicKey())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty log returned error: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("expected 0 rows on empty log, got %d", count)
|
||||||
|
}
|
||||||
|
if tip != GenesisHash {
|
||||||
|
t.Fatalf("expected GENESIS tip on empty log, got %q", tip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyChain_TamperDetected: tamper the raw line's `result` field
|
||||||
|
// (the canonicalizer sees the new bytes; HMAC mismatches the stored hash).
|
||||||
|
func TestVerifyChain_TamperDetected(t *testing.T) {
|
||||||
|
key := deterministicKey()
|
||||||
|
r1 := mkRow("CAND-T", []string{"name"}, GenesisHash, "2026-05-03T12:00:00Z")
|
||||||
|
e1 := buildEntry(r1, key, GenesisHash)
|
||||||
|
// Tamper: replace "success" with "denied" in the raw bytes ONLY.
|
||||||
|
// The struct's row_hmac (used as the "stored" comparator) stays put.
|
||||||
|
e1.Raw = []byte(strings.Replace(string(e1.Raw), `"success"`, `"denied"`, 1))
|
||||||
|
_, _, err := VerifyChain([]AuditLogEntry{e1}, key)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected hmac mismatch after tamper, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "hmac mismatch") {
|
||||||
|
t.Fatalf("expected hmac mismatch, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyChain_BadKeyRejectsValidRows: same rows + wrong key = mismatch.
|
||||||
|
func TestVerifyChain_BadKeyRejectsValidRows(t *testing.T) {
|
||||||
|
good := deterministicKey()
|
||||||
|
r1 := mkRow("CAND-BK", []string{"name"}, GenesisHash, "2026-05-03T12:00:00Z")
|
||||||
|
e1 := buildEntry(r1, good, GenesisHash)
|
||||||
|
bad := make([]byte, 32)
|
||||||
|
for i := range bad {
|
||||||
|
bad[i] = 0xff
|
||||||
|
}
|
||||||
|
_, _, err := VerifyChain([]AuditLogEntry{e1}, bad)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected hmac mismatch with wrong key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeRowHMAC_StableAcrossRuns: same row + same key always = same hash.
|
||||||
|
func TestComputeRowHMAC_StableAcrossRuns(t *testing.T) {
|
||||||
|
key := deterministicKey()
|
||||||
|
r := mkRow("CAND-S", []string{"a", "b"}, GenesisHash, "2026-05-03T12:00:00Z")
|
||||||
|
c1, _ := canonicalRowBytesFromStruct(&r)
|
||||||
|
c2, _ := canonicalRowBytesFromStruct(&r)
|
||||||
|
if string(c1) != string(c2) {
|
||||||
|
t.Fatalf("canonical bytes unstable across runs:\n c1=%s\n c2=%s", c1, c2)
|
||||||
|
}
|
||||||
|
h1 := computeRowHMAC(key, GenesisHash, c1)
|
||||||
|
h2 := computeRowHMAC(key, GenesisHash, c2)
|
||||||
|
if h1 != h2 {
|
||||||
|
t.Fatalf("hmac unstable across runs: %s vs %s", h1, h2)
|
||||||
|
}
|
||||||
|
if len(h1) != 64 {
|
||||||
|
t.Fatalf("hmac wrong length %d", len(h1))
|
||||||
|
}
|
||||||
|
// Sanity: hex-decodable.
|
||||||
|
if _, err := hex.DecodeString(h1); err != nil {
|
||||||
|
t.Fatalf("hmac not hex: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKnownAnswerVector matches a Go-computed reference. The same
|
||||||
|
// inputs must produce this exact byte string under Rust as well —
|
||||||
|
// scripts/cutover/parity/subject_audit_parity.sh runs the Rust helper
|
||||||
|
// against this exact fixture and asserts byte-identical output.
|
||||||
|
//
|
||||||
|
// If you change the fixture, rebuild Rust's parity_subject_audit + Go's
|
||||||
|
// helper and update both sides together.
|
||||||
|
func TestKnownAnswerVector(t *testing.T) {
|
||||||
|
key := deterministicKey()
|
||||||
|
r := SubjectAuditRow{
|
||||||
|
Schema: "subject_audit.v1",
|
||||||
|
Ts: time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC),
|
||||||
|
CandidateID: "WORKER-FIXED",
|
||||||
|
Accessor: AuditAccessor{
|
||||||
|
Kind: "gateway_lookup",
|
||||||
|
Daemon: "gateway",
|
||||||
|
Purpose: "parity_test",
|
||||||
|
TraceID: "trace-fixed",
|
||||||
|
},
|
||||||
|
FieldsAccessed: []string{"name"},
|
||||||
|
Result: "success",
|
||||||
|
PrevChainHash: GenesisHash,
|
||||||
|
RowHmac: "",
|
||||||
|
}
|
||||||
|
canon, err := canonicalRowBytesFromStruct(&r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canonical: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("canonical bytes: %s", canon)
|
||||||
|
hmacHex := computeRowHMAC(key, GenesisHash, canon)
|
||||||
|
t.Logf("hmac: %s", hmacHex)
|
||||||
|
// Sanity: round-trip through encoding/json + canonicalization is stable.
|
||||||
|
again, err := canonicalRowBytesFromStruct(&r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canonical 2: %v", err)
|
||||||
|
}
|
||||||
|
if string(canon) != string(again) {
|
||||||
|
t.Fatalf("canonical drift: %s vs %s", canon, again)
|
||||||
|
}
|
||||||
|
// Sanity: real HMAC against the canonical bytes.
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
mac.Write([]byte(GenesisHash))
|
||||||
|
mac.Write(canon)
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
if hmacHex != expected {
|
||||||
|
t.Fatalf("computeRowHMAC drift: %s vs %s", hmacHex, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyChain_HtmlChars_NotEscaped is the regression test for the
|
||||||
|
// 2026-05-03 opus scrum WARN: Go's json.Marshal escapes `<`, `>`, `&`
|
||||||
|
// to `<`, `>`, `&` by default; Rust's serde_json keeps
|
||||||
|
// them literal. Audit rows with these chars in any string field would
|
||||||
|
// silently break the chain across runtimes. Fix is in writeCanonical's
|
||||||
|
// marshalNoEscapeHTML helper. This test asserts canonical bytes contain
|
||||||
|
// the literal `<`, `>`, `&` (proving the fix is in place).
|
||||||
|
func TestVerifyChain_HtmlChars_NotEscaped(t *testing.T) {
|
||||||
|
r := mkRow("CAND-HTML", []string{"name"}, GenesisHash, "2026-05-03T12:00:00Z")
|
||||||
|
r.Accessor.Purpose = "error & retry" // & must NOT be &
|
||||||
|
r.Accessor.TraceID = "<HTTP-Req-Id>" // < and > must NOT be < / >
|
||||||
|
canon, err := canonicalRowBytesFromStruct(&r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canonical: %v", err)
|
||||||
|
}
|
||||||
|
s := string(canon)
|
||||||
|
// FAIL if the bytes contain Go's HTML-safe < / > / &
|
||||||
|
// escape sequences (six raw chars each: backslash, u, 0, 0, hex, hex).
|
||||||
|
// Those wouldn't match Rust's literal-char output and would silently
|
||||||
|
// break the cross-runtime HMAC chain. Note: the strings below are
|
||||||
|
// raw-string literals — the backslash + u006xx is six literal bytes,
|
||||||
|
// NOT a Go-source unicode escape.
|
||||||
|
if strings.Contains(s, "\\u003c") || strings.Contains(s, "\\u003e") || strings.Contains(s, "\\u0026") {
|
||||||
|
t.Fatalf("canonical bytes contain Go HTML-escape sequences (would diverge from Rust):\n%s", s)
|
||||||
|
}
|
||||||
|
// PASS only if the literal chars survived round-trip.
|
||||||
|
if !strings.Contains(s, "\"<HTTP-Req-Id>\"") || !strings.Contains(s, "\"error & retry\"") {
|
||||||
|
t.Fatalf("canonical bytes missing literal <>&:\n%s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyChain_RawBytesPreserveTimePrecision is the regression test
|
||||||
|
// for the 2026-05-03 WORKER-5 finding: when a row's nanoseconds end in
|
||||||
|
// 0, time.RFC3339Nano strips the trailing zero on re-marshal, producing
|
||||||
|
// different canonical bytes than Rust's chrono AutoSi (which always
|
||||||
|
// emits 9 digits). VerifyChain MUST canonicalize from the raw line
|
||||||
|
// bytes to avoid this drift. Test feeds a hand-crafted raw line whose
|
||||||
|
// ts has a trailing-zero nano value and asserts verify succeeds when
|
||||||
|
// the chain hash was computed against THOSE EXACT bytes.
|
||||||
|
func TestVerifyChain_RawBytesPreserveTimePrecision(t *testing.T) {
|
||||||
|
key := deterministicKey()
|
||||||
|
// Hand-crafted raw line exactly as Rust would write it, with
|
||||||
|
// nanoseconds=461439210 (trailing zero present).
|
||||||
|
rawNoHmac := `{"schema":"subject_audit.v1","ts":"2026-05-03T09:12:47.461439210Z","candidate_id":"WORKER-5","accessor":{"kind":"validator_lookup","daemon":"gateway","purpose":"validator_worker_lookup","trace_id":""},"fields_accessed":["exists"],"result":"not_found","prev_chain_hash":"GENESIS"}`
|
||||||
|
canonical, err := canonicalRowBytesFromRaw([]byte(rawNoHmac))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canonicalize raw: %v", err)
|
||||||
|
}
|
||||||
|
hmacHex := computeRowHMAC(key, GenesisHash, canonical)
|
||||||
|
// Compose the full row by injecting row_hmac at the end (matches
|
||||||
|
// what the Rust writer produces — declaration order + appended hmac).
|
||||||
|
rawFull := strings.TrimSuffix(rawNoHmac, "}") + `,"row_hmac":"` + hmacHex + `"}`
|
||||||
|
|
||||||
|
var row SubjectAuditRow
|
||||||
|
if err := json.Unmarshal([]byte(rawFull), &row); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
entry := AuditLogEntry{Row: row, Raw: []byte(rawFull)}
|
||||||
|
count, tip, err := VerifyChain([]AuditLogEntry{entry}, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify failed (regression: time-precision drift): %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("expected 1 row verified, got %d", count)
|
||||||
|
}
|
||||||
|
if tip != hmacHex {
|
||||||
|
t.Fatalf("tip mismatch: %s vs %s", tip, hmacHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubjectManifest_RoundTripJSON: parse a fixture JSON identical in
|
||||||
|
// shape to what crates/catalogd/src/registry.rs::put_subject writes to
|
||||||
|
// data/_catalog/subjects/<id>.json. If this fails, the Go reader is
|
||||||
|
// out of sync with the Rust writer (a Step 8 contract violation).
|
||||||
|
func TestSubjectManifest_RoundTripJSON(t *testing.T) {
|
||||||
|
src := `{
|
||||||
|
"schema": "subject_manifest.v1",
|
||||||
|
"candidate_id": "WORKER-1",
|
||||||
|
"created_at": "2026-05-03T08:22:24.571647177Z",
|
||||||
|
"updated_at": "2026-05-03T08:22:24.571647177Z",
|
||||||
|
"status": "active",
|
||||||
|
"vertical": "unknown",
|
||||||
|
"consent": {
|
||||||
|
"general_pii": {
|
||||||
|
"status": "pending_backfill_review",
|
||||||
|
"version": ""
|
||||||
|
},
|
||||||
|
"biometric": {
|
||||||
|
"status": "never_collected"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"retention": {
|
||||||
|
"general_pii_until": "2030-05-02T08:22:24.571647177Z",
|
||||||
|
"policy": "4_year_default"
|
||||||
|
},
|
||||||
|
"datasets": [
|
||||||
|
{"name": "workers_500k", "key_column": "worker_id", "key_value": "1"}
|
||||||
|
],
|
||||||
|
"safe_views": ["workers_safe"],
|
||||||
|
"audit_log_path": "_catalog/subjects/WORKER-1.audit.jsonl",
|
||||||
|
"audit_log_chain_root": ""
|
||||||
|
}`
|
||||||
|
var m SubjectManifest
|
||||||
|
if err := json.Unmarshal([]byte(src), &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if m.CandidateID != "WORKER-1" {
|
||||||
|
t.Fatalf("candidate_id wrong: %s", m.CandidateID)
|
||||||
|
}
|
||||||
|
if m.Status != "active" {
|
||||||
|
t.Fatalf("status wrong: %s", m.Status)
|
||||||
|
}
|
||||||
|
if m.Consent.GeneralPii.Status != "pending_backfill_review" {
|
||||||
|
t.Fatalf("general_pii.status wrong: %s", m.Consent.GeneralPii.Status)
|
||||||
|
}
|
||||||
|
if m.Consent.Biometric.Status != "never_collected" {
|
||||||
|
t.Fatalf("biometric.status wrong: %s", m.Consent.Biometric.Status)
|
||||||
|
}
|
||||||
|
if m.Retention.Policy != "4_year_default" {
|
||||||
|
t.Fatalf("retention.policy wrong: %s", m.Retention.Policy)
|
||||||
|
}
|
||||||
|
if len(m.Datasets) != 1 || m.Datasets[0].Name != "workers_500k" {
|
||||||
|
t.Fatalf("datasets wrong: %+v", m.Datasets)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuilderInput drives provider construction. Each field maps to one
|
||||||
|
// provider; empty fields mean "skip" (the registry won't have that
|
||||||
|
// provider — :cloud suffix or openrouter/* prefixes will 404 cleanly).
|
||||||
|
type BuilderInput struct {
|
||||||
|
OllamaURL string // local Ollama, no auth (typically http://localhost:11434)
|
||||||
|
OllamaCloudKey string // OLLAMA_CLOUD_KEY
|
||||||
|
OpenRouterKey string // OPENROUTER_API_KEY
|
||||||
|
OpenCodeKey string // OPENCODE_API_KEY
|
||||||
|
KimiKey string // KIMI_API_KEY
|
||||||
|
Timeout time.Duration // default 180s
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRegistry constructs a Registry from the input. Logs which
|
||||||
|
// providers were registered (for operator confidence at boot).
|
||||||
|
func BuildRegistry(in BuilderInput) *Registry {
|
||||||
|
if in.Timeout == 0 {
|
||||||
|
in.Timeout = 180 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
var providers []Provider
|
||||||
|
registered := []string{}
|
||||||
|
|
||||||
|
// Local Ollama always registered if URL given (no auth needed).
|
||||||
|
if in.OllamaURL != "" {
|
||||||
|
providers = append(providers, NewOllama(in.OllamaURL, in.Timeout))
|
||||||
|
registered = append(registered, "ollama")
|
||||||
|
}
|
||||||
|
if in.OllamaCloudKey != "" {
|
||||||
|
providers = append(providers, NewOllamaCloud(in.OllamaCloudKey, in.Timeout))
|
||||||
|
registered = append(registered, "ollama_cloud")
|
||||||
|
}
|
||||||
|
if in.OpenRouterKey != "" {
|
||||||
|
providers = append(providers, NewOpenRouter(in.OpenRouterKey, in.Timeout))
|
||||||
|
registered = append(registered, "openrouter")
|
||||||
|
}
|
||||||
|
if in.OpenCodeKey != "" {
|
||||||
|
providers = append(providers, NewOpenCode(in.OpenCodeKey, in.Timeout))
|
||||||
|
registered = append(registered, "opencode")
|
||||||
|
}
|
||||||
|
if in.KimiKey != "" {
|
||||||
|
providers = append(providers, NewKimi(in.KimiKey, in.Timeout))
|
||||||
|
registered = append(registered, "kimi")
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewRegistry(providers...)
|
||||||
|
slog.Info("chat registry built", "providers", registered)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveKey reads an API key with the priority chain:
|
||||||
|
// 1. Explicit env var (named by envVar)
|
||||||
|
// 2. .env file at envFilePath, looking for "<envVar>=<value>" line
|
||||||
|
// 3. "" if neither set
|
||||||
|
//
|
||||||
|
// Mirrors the Rust adapter's resolve_*_key() pattern. Empty key
|
||||||
|
// means the provider stays unregistered — operators see one fewer
|
||||||
|
// entry in the boot log instead of a 503 at first request.
|
||||||
|
//
|
||||||
|
// Phase 4 scrum fix B-1 (Opus + Kimi convergent): collapsed from
|
||||||
|
// 3-arg API to 2-arg. Previous version forced callers to pass the
|
||||||
|
// env var name twice (once for env-lookup, once for file-key-lookup),
|
||||||
|
// which let operator renames silently miss the file fallback. Now
|
||||||
|
// both lookups use the same canonical name. Operators who want a
|
||||||
|
// different file-key-name can use a different env var altogether.
|
||||||
|
func ResolveKey(envVar, envFilePath string) string {
|
||||||
|
if envVar != "" {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(envVar)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if envFilePath != "" && envVar != "" {
|
||||||
|
if v := readEnvFileVar(envFilePath, envVar); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// readEnvFileVar reads a KEY=value style env file and returns the
|
||||||
|
// value of `name`. Returns "" on any error or missing key. Stops at
|
||||||
|
// first match. No quoting/escaping — same simple shape that systemd
|
||||||
|
// EnvironmentFile= reads.
|
||||||
|
func readEnvFileVar(path, name string) string {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
prefix := name + "="
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, prefix) {
|
||||||
|
return strings.Trim(strings.TrimPrefix(line, prefix), `"'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ollama (local) provider — calls /api/chat on the local Ollama
|
||||||
|
// server. No auth needed; default URL http://localhost:11434.
|
||||||
|
//
|
||||||
|
// Bare model names route here by default (registry.defaultName=ollama),
|
||||||
|
// so "qwen3.5:latest" → ollama. Explicit "ollama/qwen3.5:latest" also
|
||||||
|
// works (prefix stripped).
|
||||||
|
type Ollama struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOllama returns a local Ollama provider. baseURL defaults to
|
||||||
|
// http://localhost:11434 when empty. timeout 0 → 180s.
|
||||||
|
func NewOllama(baseURL string, timeout time.Duration) *Ollama {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "http://localhost:11434"
|
||||||
|
}
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 180 * time.Second
|
||||||
|
}
|
||||||
|
return &Ollama{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
httpClient: &http.Client{Timeout: timeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Ollama) Name() string { return "ollama" }
|
||||||
|
|
||||||
|
// Available pings /api/tags. Cached negative result would be a
|
||||||
|
// premature optimization for G0 — Ollama is typically up. If down,
|
||||||
|
// next call gets ErrUpstream which is the right signal anyway.
|
||||||
|
func (o *Ollama) Available() bool {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", o.baseURL+"/api/tags", nil)
|
||||||
|
resp, err := o.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode/100 == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat translates Request to Ollama's /api/chat shape and back.
|
||||||
|
// Strips the optional "ollama/" prefix from req.Model.
|
||||||
|
func (o *Ollama) Chat(ctx context.Context, req Request) (*Response, error) {
|
||||||
|
model := StripPrefix(req.Model, "ollama")
|
||||||
|
|
||||||
|
options := map[string]any{}
|
||||||
|
// Pointer-valued temperature so "not set" (nil) doesn't overwrite
|
||||||
|
// Ollama's default. Only forward when caller set it explicitly.
|
||||||
|
if req.Temperature != nil {
|
||||||
|
options["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.MaxTokens > 0 {
|
||||||
|
options["num_predict"] = req.MaxTokens
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": req.Messages,
|
||||||
|
"stream": false,
|
||||||
|
// Local hot path: skip reasoning by default. qwen3 / qwen3.5 are
|
||||||
|
// thinking-capable but the inner-loop use case wants direct
|
||||||
|
// answers, not reasoning traces. Without this, low max_tokens
|
||||||
|
// budgets get consumed by thinking before any content is
|
||||||
|
// produced. Cloud tier (Ollama Cloud) inherits the same default
|
||||||
|
// — see ollama_cloud.go.
|
||||||
|
"think": false,
|
||||||
|
"options": options,
|
||||||
|
}
|
||||||
|
if req.Format == "json" {
|
||||||
|
body["format"] = "json"
|
||||||
|
}
|
||||||
|
|
||||||
|
bs, _ := json.Marshal(body)
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", o.baseURL+"/api/chat", bytes.NewReader(bs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := o.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrTimeout, "ollama")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ollama: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
rb, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return nil, fmt.Errorf("%w: ollama %d: %s", ErrUpstream, resp.StatusCode, abbrev(string(rb), 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
var ollamaResp struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
DoneReason string `json:"done_reason"` // "stop", "length", ...
|
||||||
|
PromptEvalCount int `json:"prompt_eval_count"`
|
||||||
|
EvalCount int `json:"eval_count"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rb, &ollamaResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("ollama decode: %w (body=%s)", err, abbrev(string(rb), 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Response{
|
||||||
|
Model: model,
|
||||||
|
Content: ollamaResp.Message.Content,
|
||||||
|
InputTokens: ollamaResp.PromptEvalCount,
|
||||||
|
OutputTokens: ollamaResp.EvalCount,
|
||||||
|
FinishReason: finishReasonFromOllama(ollamaResp.Done, ollamaResp.DoneReason),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishReasonFromOllama prefers Ollama's done_reason when present
|
||||||
|
// (newer Ollama 0.4+ exposes this on /api/chat). Falls back to the
|
||||||
|
// done bool for older versions. Phase 4 scrum fix B-4 (Opus WARN):
|
||||||
|
// previous logic mapped `done==true` → "stop" unconditionally, which
|
||||||
|
// hid truncations that Ollama reports as `done=true, done_reason="length"`.
|
||||||
|
// The playbook_lift judge needs this signal to detect when max_tokens
|
||||||
|
// budget ran out before the answer completed.
|
||||||
|
func finishReasonFromOllama(done bool, doneReason string) string {
|
||||||
|
if doneReason != "" {
|
||||||
|
return doneReason
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
return "stop"
|
||||||
|
}
|
||||||
|
return "length"
|
||||||
|
}
|
||||||
|
|
||||||
|
// abbrev shortens long error bodies for log/error messages without
|
||||||
|
// pulling fmt's truncation flags everywhere.
|
||||||
|
func abbrev(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OllamaCloud — Ollama Cloud (Pro plan as of 2026-04-28). Bearer
|
||||||
|
// auth via OLLAMA_CLOUD_KEY. Wire format mirrors the Rust adapter
|
||||||
|
// (crates/gateway/src/v1/ollama_cloud.rs) — system/prompt split
|
||||||
|
// against /api/generate with cloud-friendly num_predict floor.
|
||||||
|
//
|
||||||
|
// Model routing: "ollama_cloud/<model>" (prefix) and "<model>:cloud"
|
||||||
|
// (suffix) both route here. Registry.Resolve handles the suffix case;
|
||||||
|
// the prefix case strips "ollama_cloud/" before the upstream call so
|
||||||
|
// ollama.com sees the bare model name. Suffix-form models pass through
|
||||||
|
// StripPrefix unchanged (no leading prefix to strip — exactly what the
|
||||||
|
// upstream wants since "kimi-k2.6:cloud" is the canonical name on
|
||||||
|
// ollama.com).
|
||||||
|
type OllamaCloud struct {
|
||||||
|
apiKey string
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOllamaCloud(apiKey string, timeout time.Duration) *OllamaCloud {
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 180 * time.Second
|
||||||
|
}
|
||||||
|
return &OllamaCloud{
|
||||||
|
apiKey: apiKey,
|
||||||
|
baseURL: "https://ollama.com",
|
||||||
|
httpClient: &http.Client{Timeout: timeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OllamaCloud) Name() string { return "ollama_cloud" }
|
||||||
|
func (o *OllamaCloud) Available() bool { return o.apiKey != "" }
|
||||||
|
|
||||||
|
func (o *OllamaCloud) Chat(ctx context.Context, req Request) (*Response, error) {
|
||||||
|
// Strip "ollama_cloud/" prefix (Phase 4 scrum fix B-3 — Opus BLOCK).
|
||||||
|
// Pre-fix used "cloud" which never matched, so upstream got the
|
||||||
|
// prefixed model name and 400'd. ":cloud" suffix models pass
|
||||||
|
// through unchanged — that's the canonical name on ollama.com.
|
||||||
|
model := StripPrefix(req.Model, "ollama_cloud")
|
||||||
|
|
||||||
|
system, prompt := flattenMessages(req.Messages)
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": false,
|
||||||
|
// Mirror local Ollama — skip reasoning by default. Callers that
|
||||||
|
// need thinking explicitly (e.g. for Kimi K2 long reasoning)
|
||||||
|
// will get a future Request.Think field; the v0 default keeps
|
||||||
|
// outputs short and predictable.
|
||||||
|
"think": false,
|
||||||
|
"options": map[string]any{
|
||||||
|
// Cloud reasoning models need headroom — 400 floor matches
|
||||||
|
// the Rust adapter's policy for kimi-k2.6 / gpt-oss:120b.
|
||||||
|
"num_predict": maxInt(req.MaxTokens, 400),
|
||||||
|
// Cloud-tier default 0.3 when caller didn't set; honor an
|
||||||
|
// explicit value otherwise. Anthropic 4.7 deprecation
|
||||||
|
// doesn't apply here (Ollama Cloud is its own server),
|
||||||
|
// so always send.
|
||||||
|
"temperature": tempPtrOr(req.Temperature, 0.3),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if system != "" {
|
||||||
|
body["system"] = system
|
||||||
|
}
|
||||||
|
if req.Format == "json" {
|
||||||
|
body["format"] = "json"
|
||||||
|
}
|
||||||
|
|
||||||
|
bs, _ := json.Marshal(body)
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", o.baseURL+"/api/generate", bytes.NewReader(bs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+o.apiKey)
|
||||||
|
|
||||||
|
resp, err := o.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return nil, fmt.Errorf("%w: ollama_cloud", ErrTimeout)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ollama_cloud: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
rb, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return nil, fmt.Errorf("%w: ollama_cloud %d: %s", ErrUpstream, resp.StatusCode, abbrev(string(rb), 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
var cloudResp struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Response string `json:"response"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
DoneReason string `json:"done_reason"`
|
||||||
|
PromptEvalCount int `json:"prompt_eval_count"`
|
||||||
|
EvalCount int `json:"eval_count"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rb, &cloudResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("ollama_cloud decode: %w (body=%s)", err, abbrev(string(rb), 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Response{
|
||||||
|
Model: model,
|
||||||
|
Content: cloudResp.Response,
|
||||||
|
InputTokens: cloudResp.PromptEvalCount,
|
||||||
|
OutputTokens: cloudResp.EvalCount,
|
||||||
|
FinishReason: finishReasonFromOllama(cloudResp.Done, cloudResp.DoneReason),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenMessages splits a Message list into Ollama Cloud's
|
||||||
|
// /api/generate shape (single system + concatenated prompt).
|
||||||
|
// Mirrors the Rust adapter's flatten_messages_public.
|
||||||
|
func flattenMessages(messages []Message) (system, prompt string) {
|
||||||
|
var sysParts []string
|
||||||
|
var promptParts []string
|
||||||
|
for _, m := range messages {
|
||||||
|
switch m.Role {
|
||||||
|
case "system":
|
||||||
|
sysParts = append(sysParts, m.Content)
|
||||||
|
case "assistant":
|
||||||
|
promptParts = append(promptParts, "Assistant: "+m.Content)
|
||||||
|
default: // "user" or anything else
|
||||||
|
promptParts = append(promptParts, "User: "+m.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(sysParts, "\n\n"), strings.Join(promptParts, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultFloat(v, fallback float64) float64 {
|
||||||
|
if v == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func tempPtrOr(v *float64, fallback float64) float64 {
|
||||||
|
if v == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ollamaServer mocks /api/chat + /api/tags. Captures last request body.
|
||||||
|
func ollamaServer(t *testing.T, status int, respBody string) (*httptest.Server, *string) {
|
||||||
|
t.Helper()
|
||||||
|
captured := ""
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/tags":
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"models":[{"name":"qwen3.5:latest"}]}`))
|
||||||
|
return
|
||||||
|
case "/api/chat":
|
||||||
|
bs, _ := io.ReadAll(r.Body)
|
||||||
|
captured = string(bs)
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(respBody))
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
w.WriteHeader(404)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, &captured
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOllama_ChatHappyPath(t *testing.T) {
|
||||||
|
resp := `{
|
||||||
|
"model": "qwen3.5:latest",
|
||||||
|
"message": {"content": "ok"},
|
||||||
|
"done": true,
|
||||||
|
"prompt_eval_count": 7,
|
||||||
|
"eval_count": 3
|
||||||
|
}`
|
||||||
|
srv, captured := ollamaServer(t, 200, resp)
|
||||||
|
o := NewOllama(srv.URL, 5*time.Second)
|
||||||
|
|
||||||
|
out, err := o.Chat(context.Background(), Request{
|
||||||
|
Model: "qwen3.5:latest",
|
||||||
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
if out.Content != "ok" {
|
||||||
|
t.Errorf("Content = %q, want ok", out.Content)
|
||||||
|
}
|
||||||
|
if out.InputTokens != 7 || out.OutputTokens != 3 {
|
||||||
|
t.Errorf("tokens = (%d, %d), want (7, 3)", out.InputTokens, out.OutputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify captured request shape: stream=false, options.temperature
|
||||||
|
// surfaced.
|
||||||
|
var sent map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(*captured), &sent); err != nil {
|
||||||
|
t.Fatalf("parse captured: %v", err)
|
||||||
|
}
|
||||||
|
if sent["stream"] != false {
|
||||||
|
t.Errorf("stream should be false, got %v", sent["stream"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOllama_StripsExplicitPrefix(t *testing.T) {
|
||||||
|
srv, captured := ollamaServer(t, 200, `{"message":{"content":""},"done":true}`)
|
||||||
|
o := NewOllama(srv.URL, 5*time.Second)
|
||||||
|
_, err := o.Chat(context.Background(), Request{Model: "ollama/qwen3.5:latest"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
var sent map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(*captured), &sent)
|
||||||
|
if sent["model"] != "qwen3.5:latest" {
|
||||||
|
t.Errorf("upstream model = %v, want qwen3.5:latest (prefix stripped)", sent["model"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOllama_FormatJSON(t *testing.T) {
|
||||||
|
srv, captured := ollamaServer(t, 200, `{"message":{"content":"{}"},"done":true}`)
|
||||||
|
o := NewOllama(srv.URL, 5*time.Second)
|
||||||
|
_, _ = o.Chat(context.Background(), Request{Model: "qwen3.5:latest", Format: "json"})
|
||||||
|
if !strings.Contains(*captured, `"format":"json"`) {
|
||||||
|
t.Errorf("Format=json should set top-level format=json; captured=%s", *captured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOllama_Available(t *testing.T) {
|
||||||
|
srv, _ := ollamaServer(t, 200, "{}")
|
||||||
|
o := NewOllama(srv.URL, 5*time.Second)
|
||||||
|
if !o.Available() {
|
||||||
|
t.Errorf("server is up; Available should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOllama_UpstreamError(t *testing.T) {
|
||||||
|
srv, _ := ollamaServer(t, 500, `{"error":"out of memory"}`)
|
||||||
|
o := NewOllama(srv.URL, 5*time.Second)
|
||||||
|
_, err := o.Chat(context.Background(), Request{Model: "qwen3.5:latest"})
|
||||||
|
if !errors.Is(err, ErrUpstream) {
|
||||||
|
t.Errorf("500 should ErrUpstream; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOllamaCloud_FlattenMessages(t *testing.T) {
|
||||||
|
system, prompt := flattenMessages([]Message{
|
||||||
|
{Role: "system", Content: "You are helpful."},
|
||||||
|
{Role: "user", Content: "hi"},
|
||||||
|
{Role: "assistant", Content: "hello!"},
|
||||||
|
{Role: "user", Content: "how are you?"},
|
||||||
|
})
|
||||||
|
if system != "You are helpful." {
|
||||||
|
t.Errorf("system = %q, want 'You are helpful.'", system)
|
||||||
|
}
|
||||||
|
if !strings.Contains(prompt, "User: hi") || !strings.Contains(prompt, "Assistant: hello!") || !strings.Contains(prompt, "User: how are you?") {
|
||||||
|
t.Errorf("prompt missing role tags: %q", prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openaiCompat implements the OpenAI Chat Completions wire format,
|
||||||
|
// which OpenRouter / OpenCode / Kimi all speak. Differences between
|
||||||
|
// those three providers are limited to:
|
||||||
|
// - base URL (e.g. /v1/chat/completions vs /zen/v1/chat/completions)
|
||||||
|
// - prefix stripped from req.Model
|
||||||
|
// - bearer token source (different env vars)
|
||||||
|
//
|
||||||
|
// Each concrete provider wraps this with its own constructor pinning
|
||||||
|
// those values.
|
||||||
|
type openaiCompat struct {
|
||||||
|
name string
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
prefix string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOpenAICompat(name, baseURL, apiKey, prefix string, timeout time.Duration) *openaiCompat {
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 180 * time.Second
|
||||||
|
}
|
||||||
|
return &openaiCompat{
|
||||||
|
name: name,
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
apiKey: apiKey,
|
||||||
|
prefix: prefix,
|
||||||
|
httpClient: &http.Client{Timeout: timeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *openaiCompat) Name() string { return c.name }
|
||||||
|
func (c *openaiCompat) Available() bool { return c.apiKey != "" }
|
||||||
|
|
||||||
|
func (c *openaiCompat) Chat(ctx context.Context, req Request) (*Response, error) {
|
||||||
|
model := StripPrefix(req.Model, c.prefix)
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": req.Messages,
|
||||||
|
"stream": false,
|
||||||
|
}
|
||||||
|
// Anthropic 4.7 (via OpenCode → Anthropic) rejects the temperature
|
||||||
|
// field entirely with "temperature is deprecated for this model".
|
||||||
|
// Send only when caller explicitly set it. Nil = use upstream
|
||||||
|
// default. Keep this true for ALL openai-compat providers — being
|
||||||
|
// permissive about field omission is better than maintaining a
|
||||||
|
// per-model deprecation table.
|
||||||
|
if req.Temperature != nil {
|
||||||
|
body["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.MaxTokens > 0 {
|
||||||
|
body["max_tokens"] = req.MaxTokens
|
||||||
|
}
|
||||||
|
if req.Format == "json" {
|
||||||
|
// OpenAI-compat response_format. OpenRouter passes through;
|
||||||
|
// OpenCode + Kimi accept it. Worst case the upstream ignores it.
|
||||||
|
body["response_format"] = map[string]any{"type": "json_object"}
|
||||||
|
}
|
||||||
|
|
||||||
|
bs, _ := json.Marshal(body)
|
||||||
|
url := c.baseURL + "/chat/completions"
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
// HTTP-Referer + X-Title are OpenRouter-specific but harmless on
|
||||||
|
// other providers — they pass them through to upstream attribution.
|
||||||
|
httpReq.Header.Set("HTTP-Referer", "https://golanglakehouse.local")
|
||||||
|
httpReq.Header.Set("X-Title", "Lakehouse-Go")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrTimeout, c.name)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s: %w", c.name, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
rb, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return nil, fmt.Errorf("%w: %s %d: %s", ErrUpstream, c.name, resp.StatusCode, abbrev(string(rb), 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
var oaResp struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rb, &oaResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s decode: %w (body=%s)", c.name, err, abbrev(string(rb), 200))
|
||||||
|
}
|
||||||
|
if len(oaResp.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: %s returned 0 choices: %s", ErrUpstream, c.name, abbrev(string(rb), 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Response{
|
||||||
|
Model: model,
|
||||||
|
Content: oaResp.Choices[0].Message.Content,
|
||||||
|
InputTokens: oaResp.Usage.PromptTokens,
|
||||||
|
OutputTokens: oaResp.Usage.CompletionTokens,
|
||||||
|
FinishReason: oaResp.Choices[0].FinishReason,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOpenRouter returns a provider for openrouter.ai. apiKey
|
||||||
|
// resolved at construction (typically from OPENROUTER_API_KEY env or
|
||||||
|
// /etc/lakehouse/openrouter.env).
|
||||||
|
func NewOpenRouter(apiKey string, timeout time.Duration) *openaiCompat {
|
||||||
|
return newOpenAICompat("openrouter", "https://openrouter.ai/api/v1", apiKey, "openrouter", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOpenCode returns a provider for OpenCode's unified Zen+Go endpoint.
|
||||||
|
// One key reaches Anthropic Opus, GPT-5, Gemini 3.1, Kimi K2.6,
|
||||||
|
// DeepSeek, GLM, Qwen, plus 4 free-tier models.
|
||||||
|
func NewOpenCode(apiKey string, timeout time.Duration) *openaiCompat {
|
||||||
|
return newOpenAICompat("opencode", "https://opencode.ai/zen/v1", apiKey, "opencode", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKimi returns a provider for the direct Kimi For Coding endpoint.
|
||||||
|
// `api.kimi.com` is a separate account system from api.moonshot.ai —
|
||||||
|
// keys are NOT interchangeable.
|
||||||
|
func NewKimi(apiKey string, timeout time.Duration) *openaiCompat {
|
||||||
|
return newOpenAICompat("kimi", "https://api.kimi.com/coding/v1", apiKey, "kimi", timeout)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openaiServer returns an httptest server that mocks an OpenAI Chat
|
||||||
|
// Completions endpoint. Handler captures the last request body for
|
||||||
|
// assertion.
|
||||||
|
func openaiServer(t *testing.T, status int, respBody string) (*httptest.Server, *string) {
|
||||||
|
t.Helper()
|
||||||
|
captured := ""
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bs, _ := io.ReadAll(r.Body)
|
||||||
|
captured = string(bs)
|
||||||
|
// Ensure Authorization header was set (Bearer prefix).
|
||||||
|
if auth := r.Header.Get("Authorization"); !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
t.Errorf("missing Bearer auth header; got %q", auth)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(respBody))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, &captured
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompat_HappyPath(t *testing.T) {
|
||||||
|
resp := `{
|
||||||
|
"model": "anthropic/claude-opus-4-7",
|
||||||
|
"choices": [{"message":{"content":"hello world"}, "finish_reason":"stop"}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
|
||||||
|
}`
|
||||||
|
srv, captured := openaiServer(t, 200, resp)
|
||||||
|
|
||||||
|
p := newOpenAICompat("openrouter", srv.URL+"/v1", "test-key", "openrouter", 5*time.Second)
|
||||||
|
out, err := p.Chat(context.Background(), Request{
|
||||||
|
Model: "openrouter/anthropic/claude-opus-4-7",
|
||||||
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
if out.Content != "hello world" {
|
||||||
|
t.Errorf("Content = %q, want hello world", out.Content)
|
||||||
|
}
|
||||||
|
// Provider strips its prefix before sending to upstream.
|
||||||
|
var sent map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(*captured), &sent); err != nil {
|
||||||
|
t.Fatalf("parse captured: %v", err)
|
||||||
|
}
|
||||||
|
if sent["model"] != "anthropic/claude-opus-4-7" {
|
||||||
|
t.Errorf("upstream got model = %v, want anthropic/claude-opus-4-7 (prefix stripped)", sent["model"])
|
||||||
|
}
|
||||||
|
// Token accounting carried through.
|
||||||
|
if out.InputTokens != 10 || out.OutputTokens != 5 {
|
||||||
|
t.Errorf("tokens = (%d, %d), want (10, 5)", out.InputTokens, out.OutputTokens)
|
||||||
|
}
|
||||||
|
if out.FinishReason != "stop" {
|
||||||
|
t.Errorf("FinishReason = %q, want stop", out.FinishReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompat_FormatJSON(t *testing.T) {
|
||||||
|
resp := `{"choices":[{"message":{"content":"{\"a\":1}"},"finish_reason":"stop"}],"usage":{}}`
|
||||||
|
srv, captured := openaiServer(t, 200, resp)
|
||||||
|
p := newOpenAICompat("opencode", srv.URL+"/zen/v1", "test-key", "opencode", 5*time.Second)
|
||||||
|
|
||||||
|
_, err := p.Chat(context.Background(), Request{
|
||||||
|
Model: "opencode/claude-opus-4-7",
|
||||||
|
Messages: []Message{{Role: "user", Content: "give me JSON"}},
|
||||||
|
Format: "json",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
// Format=json should set response_format on the upstream call.
|
||||||
|
if !strings.Contains(*captured, `"response_format"`) || !strings.Contains(*captured, `"json_object"`) {
|
||||||
|
t.Errorf("Format=json should set response_format json_object; captured=%s", *captured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompat_UpstreamError(t *testing.T) {
|
||||||
|
srv, _ := openaiServer(t, 429, `{"error":"rate limited"}`)
|
||||||
|
p := newOpenAICompat("openrouter", srv.URL+"/v1", "test-key", "openrouter", 5*time.Second)
|
||||||
|
_, err := p.Chat(context.Background(), Request{Model: "openrouter/x"})
|
||||||
|
if !errors.Is(err, ErrUpstream) {
|
||||||
|
t.Errorf("429 should be ErrUpstream; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompat_ZeroChoices(t *testing.T) {
|
||||||
|
srv, _ := openaiServer(t, 200, `{"choices":[],"usage":{}}`)
|
||||||
|
p := newOpenAICompat("openrouter", srv.URL+"/v1", "test-key", "openrouter", 5*time.Second)
|
||||||
|
_, err := p.Chat(context.Background(), Request{Model: "openrouter/x"})
|
||||||
|
if !errors.Is(err, ErrUpstream) {
|
||||||
|
t.Errorf("zero-choices should ErrUpstream; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompat_EmptyKeyUnavailable(t *testing.T) {
|
||||||
|
p := newOpenAICompat("openrouter", "https://example.com", "", "openrouter", 5*time.Second)
|
||||||
|
if p.Available() {
|
||||||
|
t.Errorf("empty key should make provider unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOllamaCloud_StripsCorrectPrefix locks in scrum fix B-3.
|
||||||
|
// Pre-fix: StripPrefix(req.Model, "cloud") never matched "ollama_cloud/<m>"
|
||||||
|
// because the prefix string was "cloud/" not "ollama_cloud/". Result was
|
||||||
|
// the upstream got the prefixed model name and 400'd. This test would
|
||||||
|
// have caught it had it existed.
|
||||||
|
func TestOllamaCloud_StripsCorrectPrefix(t *testing.T) {
|
||||||
|
captured := ""
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bs, _ := io.ReadAll(r.Body)
|
||||||
|
captured = string(bs)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte(`{"model":"deepseek-v3.2","response":"ok","done":true,"done_reason":"stop"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
// Inject the test server URL via reflection-free constructor —
|
||||||
|
// just build the struct directly with our overrides since
|
||||||
|
// NewOllamaCloud hardcodes ollama.com.
|
||||||
|
o := &OllamaCloud{
|
||||||
|
apiKey: "test-key",
|
||||||
|
baseURL: srv.URL,
|
||||||
|
httpClient: srv.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
expected string // model the upstream should see
|
||||||
|
}{
|
||||||
|
// Prefix form (Phase 4 scrum fix B-3): strip "ollama_cloud/"
|
||||||
|
{"ollama_cloud/deepseek-v3.2", "deepseek-v3.2"},
|
||||||
|
{"ollama_cloud/kimi-k2.6:cloud", "kimi-k2.6:cloud"},
|
||||||
|
// Suffix form: nothing to strip (already canonical)
|
||||||
|
{"kimi-k2.6:cloud", "kimi-k2.6:cloud"},
|
||||||
|
{"qwen3-coder:480b", "qwen3-coder:480b"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
captured = ""
|
||||||
|
_, err := o.Chat(context.Background(), Request{
|
||||||
|
Model: c.input,
|
||||||
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Chat(%q): %v", c.input, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var sent map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(captured), &sent); err != nil {
|
||||||
|
t.Fatalf("parse captured: %v", err)
|
||||||
|
}
|
||||||
|
got, _ := sent["model"].(string)
|
||||||
|
if got != c.expected {
|
||||||
|
t.Errorf("Chat(%q): upstream got model=%q, want %q", c.input, got, c.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishReasonFromOllama_PrefersDoneReason(t *testing.T) {
|
||||||
|
// Phase 4 scrum fix B-4: done_reason takes precedence when present.
|
||||||
|
cases := []struct {
|
||||||
|
done bool
|
||||||
|
doneReason string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{true, "stop", "stop"},
|
||||||
|
{true, "length", "length"}, // truncation surfaced — pre-fix lost this
|
||||||
|
{true, "", "stop"}, // legacy fallback
|
||||||
|
{false, "", "length"},
|
||||||
|
{false, "stop", "stop"}, // done_reason wins regardless of done
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := finishReasonFromOllama(c.done, c.doneReason)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("finishReasonFromOllama(%v, %q) = %q, want %q", c.done, c.doneReason, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_AvailableLooksUpDirectly(t *testing.T) {
|
||||||
|
// Phase 4 scrum fix B-2: Available() reports registry membership,
|
||||||
|
// not routing. Unregistered name → false; registered + Available()
|
||||||
|
// → true; registered but empty key → false.
|
||||||
|
registered := newFake("openrouter", true)
|
||||||
|
disabled := newFake("opencode", false) // "registered" but no key
|
||||||
|
r := NewRegistry(registered, disabled)
|
||||||
|
if !r.Available("openrouter") {
|
||||||
|
t.Errorf("openrouter should be Available")
|
||||||
|
}
|
||||||
|
if r.Available("opencode") {
|
||||||
|
t.Errorf("opencode has no key — Available should be false")
|
||||||
|
}
|
||||||
|
if r.Available("nonexistent") {
|
||||||
|
t.Errorf("nonexistent should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewProviderConstructors(t *testing.T) {
|
||||||
|
// Smoke: each public constructor produces a working provider with
|
||||||
|
// the right name/prefix and respects empty-key=unavailable.
|
||||||
|
or := NewOpenRouter("test-key", 0)
|
||||||
|
if or.Name() != "openrouter" || !or.Available() {
|
||||||
|
t.Errorf("openrouter constructor wrong: %s avail=%v", or.Name(), or.Available())
|
||||||
|
}
|
||||||
|
oc := NewOpenCode("test-key", 0)
|
||||||
|
if oc.Name() != "opencode" || !oc.Available() {
|
||||||
|
t.Errorf("opencode constructor wrong: %s avail=%v", oc.Name(), oc.Available())
|
||||||
|
}
|
||||||
|
km := NewKimi("", 0) // empty key → unavailable
|
||||||
|
if km.Available() {
|
||||||
|
t.Errorf("kimi with empty key should be unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry resolves a model name to its Provider. Lookup is by the
|
||||||
|
// first slash-delimited prefix; bare names (no slash) fall through to
|
||||||
|
// the configured default provider — typically `ollama` so local
|
||||||
|
// model names like `qwen3.5:latest` work without a prefix.
|
||||||
|
//
|
||||||
|
// Mirrors the Rust gateway's resolve_provider() pattern from
|
||||||
|
// crates/gateway/src/v1/mod.rs.
|
||||||
|
type Registry struct {
|
||||||
|
providers map[string]Provider // name → provider
|
||||||
|
defaultName string // resolved when no prefix matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry builds a registry from a list of providers. The first
|
||||||
|
// "ollama" provider becomes the bare-name default; callers can
|
||||||
|
// override via SetDefault.
|
||||||
|
func NewRegistry(providers ...Provider) *Registry {
|
||||||
|
r := &Registry{providers: make(map[string]Provider, len(providers))}
|
||||||
|
for _, p := range providers {
|
||||||
|
r.providers[p.Name()] = p
|
||||||
|
if r.defaultName == "" && p.Name() == "ollama" {
|
||||||
|
r.defaultName = "ollama"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds or replaces a provider. Used after construction (e.g.
|
||||||
|
// for tests injecting fakes).
|
||||||
|
func (r *Registry) Register(p Provider) {
|
||||||
|
r.providers[p.Name()] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefault sets the provider used when no prefix matches. Empty
|
||||||
|
// model names always 404 — the default only kicks in for unprefixed
|
||||||
|
// non-empty names.
|
||||||
|
func (r *Registry) SetDefault(name string) {
|
||||||
|
r.defaultName = name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names returns the registered provider names, sorted (deterministic
|
||||||
|
// output for /v1/chat/providers listing).
|
||||||
|
func (r *Registry) Names() []string {
|
||||||
|
out := make([]string, 0, len(r.providers))
|
||||||
|
for n := range r.providers {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available reports whether the named provider is registered AND
|
||||||
|
// its Available() method returns true. Returns false for unregistered
|
||||||
|
// names — used by /v1/chat/providers (Phase 4 scrum fix B-2: replaces
|
||||||
|
// the prior synthetic-route probe that mis-routed via Resolve).
|
||||||
|
func (r *Registry) Available(name string) bool {
|
||||||
|
p, ok := r.providers[name]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return p.Available()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the Provider for a model name. Resolution rules:
|
||||||
|
//
|
||||||
|
// 1. Empty model → ErrProviderNotFound
|
||||||
|
// 2. Suffix ":cloud" → ollama_cloud (e.g. "kimi-k2.6:cloud")
|
||||||
|
// 3. Prefix match (e.g. "openrouter/...") → that provider
|
||||||
|
// 4. No prefix or unknown prefix → default provider (typically ollama)
|
||||||
|
// 5. No default registered → ErrProviderNotFound
|
||||||
|
//
|
||||||
|
// The suffix rule mirrors the Rust gateway and the Ollama Cloud
|
||||||
|
// upstream's own naming convention — kimi-k2.6:cloud, qwen3-coder:480b
|
||||||
|
// (when on cloud) etc. Without it, every cloud model would need a
|
||||||
|
// "cloud/" prefix in lakehouse.toml, which clashes with the Ollama
|
||||||
|
// upstream that wants the bare suffix-named model.
|
||||||
|
func (r *Registry) Resolve(model string) (Provider, error) {
|
||||||
|
if model == "" {
|
||||||
|
return nil, fmt.Errorf("%w: empty model name", ErrProviderNotFound)
|
||||||
|
}
|
||||||
|
// Suffix detection — `:cloud` always means Ollama Cloud.
|
||||||
|
if strings.HasSuffix(model, ":cloud") {
|
||||||
|
if p, ok := r.providers["ollama_cloud"]; ok {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
// :cloud suffix with no ollama_cloud provider → 404. Don't
|
||||||
|
// silently fall through to local Ollama; that would burn the
|
||||||
|
// model name on a provider that doesn't have it.
|
||||||
|
return nil, fmt.Errorf("%w: %q has :cloud suffix but ollama_cloud provider is not registered", ErrProviderNotFound, model)
|
||||||
|
}
|
||||||
|
// Prefix match: "openrouter/anthropic/claude-opus-4-7" splits on
|
||||||
|
// first "/". Multi-segment provider names not supported (none
|
||||||
|
// shipped use them).
|
||||||
|
if idx := strings.Index(model, "/"); idx > 0 {
|
||||||
|
prefix := model[:idx]
|
||||||
|
if p, ok := r.providers[prefix]; ok {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
// Unknown prefix — falls through to default. Lets bare model
|
||||||
|
// names with slashes (e.g. "anthropic/claude-3.5") still hit
|
||||||
|
// ollama if that's how the operator named local models.
|
||||||
|
}
|
||||||
|
if r.defaultName == "" {
|
||||||
|
return nil, fmt.Errorf("%w: %q", ErrProviderNotFound, model)
|
||||||
|
}
|
||||||
|
p, ok := r.providers[r.defaultName]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: default provider %q not registered", ErrProviderNotFound, r.defaultName)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat is the dispatcher entry point: resolve provider, dispatch,
|
||||||
|
// stamp telemetry on the response. Returns ErrProviderDisabled when
|
||||||
|
// the resolved provider isn't Available() (caller should map to 503).
|
||||||
|
func (r *Registry) Chat(ctx context.Context, req Request) (*Response, error) {
|
||||||
|
p, err := r.Resolve(req.Model)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !p.Available() {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrProviderDisabled, p.Name())
|
||||||
|
}
|
||||||
|
t0 := time.Now()
|
||||||
|
resp, err := p.Chat(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.LatencyMs = time.Since(t0).Milliseconds()
|
||||||
|
resp.Provider = p.Name()
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripPrefix removes the leading "<provider>/" from model when
|
||||||
|
// present. Helpers for upstream calls — providers that need the bare
|
||||||
|
// model name (e.g. OpenRouter sees "anthropic/claude-opus-4-7", not
|
||||||
|
// "openrouter/anthropic/claude-opus-4-7") use this.
|
||||||
|
func StripPrefix(model, prefix string) string {
|
||||||
|
want := prefix + "/"
|
||||||
|
if strings.HasPrefix(model, want) {
|
||||||
|
return model[len(want):]
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeProvider is a minimal Provider for registry testing — no HTTP,
|
||||||
|
// just records what model name reached it.
|
||||||
|
type fakeProvider struct {
|
||||||
|
name string
|
||||||
|
available bool
|
||||||
|
got Request // last request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeProvider) Name() string { return f.name }
|
||||||
|
func (f *fakeProvider) Available() bool { return f.available }
|
||||||
|
func (f *fakeProvider) Chat(_ context.Context, req Request) (*Response, error) {
|
||||||
|
f.got = req
|
||||||
|
return &Response{Model: req.Model, Content: "ok from " + f.name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFake(name string, available bool) *fakeProvider {
|
||||||
|
return &fakeProvider{name: name, available: available}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_ResolveByPrefix(t *testing.T) {
|
||||||
|
ollama := newFake("ollama", true)
|
||||||
|
openrouter := newFake("openrouter", true)
|
||||||
|
opencode := newFake("opencode", true)
|
||||||
|
kimi := newFake("kimi", true)
|
||||||
|
|
||||||
|
r := NewRegistry(ollama, openrouter, opencode, kimi)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
model string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"openrouter/anthropic/claude-opus-4-7", "openrouter"},
|
||||||
|
{"opencode/claude-opus-4-7", "opencode"},
|
||||||
|
{"kimi/kimi-for-coding", "kimi"},
|
||||||
|
{"ollama/qwen3.5:latest", "ollama"}, // explicit prefix
|
||||||
|
{"qwen3.5:latest", "ollama"}, // bare → default
|
||||||
|
{"unknown/foo/bar", "ollama"}, // unknown prefix → default
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
p, err := r.Resolve(c.model)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Resolve(%q): unexpected error: %v", c.model, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Name() != c.want {
|
||||||
|
t.Errorf("Resolve(%q) = %s, want %s", c.model, p.Name(), c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_ResolveCloudSuffix(t *testing.T) {
|
||||||
|
ollama := newFake("ollama", true)
|
||||||
|
cloud := newFake("ollama_cloud", true)
|
||||||
|
r := NewRegistry(ollama, cloud)
|
||||||
|
|
||||||
|
// :cloud suffix routes to ollama_cloud regardless of any prefix.
|
||||||
|
p, err := r.Resolve("kimi-k2.6:cloud")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve kimi-k2.6:cloud: %v", err)
|
||||||
|
}
|
||||||
|
if p.Name() != "ollama_cloud" {
|
||||||
|
t.Errorf("kimi-k2.6:cloud should route to ollama_cloud, got %s", p.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without ollama_cloud registered, :cloud → ErrProviderNotFound
|
||||||
|
// (don't silently fall through to local).
|
||||||
|
rNoCloud := NewRegistry(ollama)
|
||||||
|
if _, err := rNoCloud.Resolve("kimi-k2.6:cloud"); !errors.Is(err, ErrProviderNotFound) {
|
||||||
|
t.Errorf("missing ollama_cloud should ErrProviderNotFound; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_ResolveErrors(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
|
||||||
|
// Empty model
|
||||||
|
if _, err := r.Resolve(""); !errors.Is(err, ErrProviderNotFound) {
|
||||||
|
t.Errorf("empty model should ErrProviderNotFound; got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No providers registered, any model → 404
|
||||||
|
if _, err := r.Resolve("openrouter/foo"); !errors.Is(err, ErrProviderNotFound) {
|
||||||
|
t.Errorf("unregistered openrouter should 404; got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := r.Resolve("bare-model"); !errors.Is(err, ErrProviderNotFound) {
|
||||||
|
t.Errorf("bare with no default should 404; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_ChatStampsTelemetry(t *testing.T) {
|
||||||
|
ollama := newFake("ollama", true)
|
||||||
|
r := NewRegistry(ollama)
|
||||||
|
|
||||||
|
resp, err := r.Chat(context.Background(), Request{Model: "qwen3.5:latest", Messages: []Message{{Role: "user", Content: "hi"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Provider != "ollama" {
|
||||||
|
t.Errorf("Provider should be stamped to %q, got %q", "ollama", resp.Provider)
|
||||||
|
}
|
||||||
|
if resp.LatencyMs < 0 {
|
||||||
|
t.Errorf("LatencyMs negative: %d", resp.LatencyMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_ChatProviderUnavailable(t *testing.T) {
|
||||||
|
openrouter := newFake("openrouter", false) // no key
|
||||||
|
r := NewRegistry(openrouter)
|
||||||
|
|
||||||
|
_, err := r.Chat(context.Background(), Request{Model: "openrouter/foo"})
|
||||||
|
if !errors.Is(err, ErrProviderDisabled) {
|
||||||
|
t.Errorf("unavailable provider should ErrProviderDisabled; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripPrefix(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
model, prefix, want string
|
||||||
|
}{
|
||||||
|
{"openrouter/anthropic/claude", "openrouter", "anthropic/claude"},
|
||||||
|
{"opencode/claude-opus-4-7", "opencode", "claude-opus-4-7"},
|
||||||
|
{"qwen3.5:latest", "ollama", "qwen3.5:latest"}, // no prefix to strip
|
||||||
|
{"ollama/qwen3.5:latest", "ollama", "qwen3.5:latest"}, // explicit ollama prefix
|
||||||
|
{"kimi-k2.6:cloud", "cloud", "kimi-k2.6:cloud"}, // suffix doesn't trigger strip
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := StripPrefix(c.model, c.prefix); got != c.want {
|
||||||
|
t.Errorf("StripPrefix(%q, %q) = %q, want %q", c.model, c.prefix, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Names(t *testing.T) {
|
||||||
|
r := NewRegistry(
|
||||||
|
newFake("zz", true),
|
||||||
|
newFake("aa", true),
|
||||||
|
newFake("mm", true),
|
||||||
|
)
|
||||||
|
names := r.Names()
|
||||||
|
if len(names) != 3 || names[0] != "aa" || names[1] != "mm" || names[2] != "zz" {
|
||||||
|
t.Errorf("Names() = %v, want sorted [aa mm zz]", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-stamp sanity — the dispatcher should never produce LatencyMs
|
||||||
|
// in the past.
|
||||||
|
func TestRegistry_LatencyMonotonic(t *testing.T) {
|
||||||
|
ollama := newFake("ollama", true)
|
||||||
|
r := NewRegistry(ollama)
|
||||||
|
t0 := time.Now()
|
||||||
|
resp, err := r.Chat(context.Background(), Request{Model: "qwen3.5:latest"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
elapsed := time.Since(t0).Milliseconds()
|
||||||
|
if resp.LatencyMs > elapsed+1 {
|
||||||
|
t.Errorf("LatencyMs %d > elapsed %d (impossible)", resp.LatencyMs, elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Package chat provides the LLM chat abstraction over multiple
|
||||||
|
// providers (local Ollama, Ollama Cloud, OpenRouter, OpenCode, Kimi).
|
||||||
|
//
|
||||||
|
// Architecture per docs/SPEC.md §1 (chatd) + the small-model pipeline
|
||||||
|
// vision (project_small_model_pipeline_vision.md):
|
||||||
|
//
|
||||||
|
// - Hot path runs on local Ollama via cheap `qwen3.5:latest` repeats.
|
||||||
|
// - Oversight tier reaches Ollama Cloud (Pro plan) for tasks needing
|
||||||
|
// more capacity (kimi-k2.6:cloud, qwen3-coder:480b, deepseek-v3.2).
|
||||||
|
// - Frontier escalation goes through OpenRouter (claude-opus-4-7,
|
||||||
|
// gpt-5, kimi-k2-0905) or OpenCode (free-tier opus-4-7) for
|
||||||
|
// blockers that need full-scope reasoning.
|
||||||
|
//
|
||||||
|
// Provider resolution is by model-name prefix (mirroring the Rust
|
||||||
|
// gateway's pattern from crates/gateway/src/v1/mod.rs):
|
||||||
|
//
|
||||||
|
// ollama/<model> → ollama (or bare names like "qwen3.5:latest")
|
||||||
|
// ollama_cloud/<model> → ollama_cloud
|
||||||
|
// openrouter/<vendor>/<m> → openrouter
|
||||||
|
// opencode/<model> → opencode
|
||||||
|
// kimi/<model> → kimi
|
||||||
|
//
|
||||||
|
// The prefix is stripped before the upstream call.
|
||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for the upper layers (gateway response codes,
|
||||||
|
// observability, retry decisions).
|
||||||
|
var (
|
||||||
|
// ErrProviderNotFound — model name didn't resolve to any registered
|
||||||
|
// provider. Maps to 404 at the HTTP layer.
|
||||||
|
ErrProviderNotFound = errors.New("provider not found for model")
|
||||||
|
|
||||||
|
// ErrProviderDisabled — the provider was registered but its key
|
||||||
|
// resolved empty. Maps to 503 at the HTTP layer (operator can fix
|
||||||
|
// by setting the env var; not a client bug).
|
||||||
|
ErrProviderDisabled = errors.New("provider disabled (no auth key)")
|
||||||
|
|
||||||
|
// ErrUpstream — the provider returned a non-2xx. Body included in
|
||||||
|
// the wrapped error message. Maps to 502.
|
||||||
|
ErrUpstream = errors.New("provider upstream error")
|
||||||
|
|
||||||
|
// ErrTimeout — provider call exceeded the configured timeout.
|
||||||
|
// Maps to 504.
|
||||||
|
ErrTimeout = errors.New("provider timeout")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is one entry in a Chat request's conversation. Role is
|
||||||
|
// "system", "user", or "assistant" — matches OpenAI/Anthropic shapes.
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request is the provider-neutral chat request. Providers translate
|
||||||
|
// to their wire format (OpenAI Chat Completions, Ollama /api/chat,
|
||||||
|
// Ollama Cloud /api/generate, etc.). Format="json" asks the provider
|
||||||
|
// to return JSON-only output (constrained decoding when supported).
|
||||||
|
//
|
||||||
|
// Temperature is *float64 (pointer) so callers can distinguish "not
|
||||||
|
// set" (nil) from "set to 0" (deterministic). Anthropic 4.7 rejects
|
||||||
|
// the temperature field entirely, so providers omit it from the
|
||||||
|
// upstream call when this is nil.
|
||||||
|
type Request struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Format string `json:"format,omitempty"` // "" or "json"
|
||||||
|
Stream bool `json:"stream,omitempty"` // ignored in G0 — chatd is synchronous
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temp is a convenience for callers building a Request literal:
|
||||||
|
//
|
||||||
|
// chat.Request{Model: "...", Temperature: chat.Temp(0.0), ...}
|
||||||
|
func Temp(v float64) *float64 { return &v }
|
||||||
|
|
||||||
|
// Response is the provider-neutral chat response. LatencyMs is filled
|
||||||
|
// by the dispatcher (Provider implementations don't track it).
|
||||||
|
// Provider names the resolved provider for telemetry / debug.
|
||||||
|
type Response struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
InputTokens int `json:"input_tokens,omitempty"`
|
||||||
|
OutputTokens int `json:"output_tokens,omitempty"`
|
||||||
|
FinishReason string `json:"finish_reason,omitempty"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is the contract every backend must implement. Implementations
|
||||||
|
// must be safe for concurrent calls — the dispatcher shares one
|
||||||
|
// instance across all incoming requests.
|
||||||
|
//
|
||||||
|
// Chat receives the full Request including the prefixed model name
|
||||||
|
// (e.g. "openrouter/anthropic/claude-opus-4-7"). Implementations
|
||||||
|
// strip their prefix before the upstream call.
|
||||||
|
type Provider interface {
|
||||||
|
// Name returns the provider's short identifier (matches the prefix
|
||||||
|
// in model names). Used for telemetry and Response.Provider.
|
||||||
|
Name() string
|
||||||
|
|
||||||
|
// Chat performs one round-trip. Should respect ctx cancellation.
|
||||||
|
Chat(ctx context.Context, req Request) (*Response, error)
|
||||||
|
|
||||||
|
// Available reports whether the provider is ready to serve. False
|
||||||
|
// means missing auth key or unreachable upstream — dispatcher
|
||||||
|
// returns ErrProviderDisabled for callers using this provider.
|
||||||
|
Available() bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
// Package corpusingest is the generalized text→vector ingestion
|
||||||
|
// pipeline. Originally extracted from scripts/staffing_500k/main.go;
|
||||||
|
// reusable by any corpus-builder script that needs to embed a stream
|
||||||
|
// of (id, text, metadata) rows and push them into a vectord index.
|
||||||
|
//
|
||||||
|
// Design: per-corpus Source impls own the parsing/column-mapping;
|
||||||
|
// this package owns the parallel-embed dispatcher, batching, vectord
|
||||||
|
// index lifecycle, and progress reporting. Adding a corpus is one
|
||||||
|
// Source struct + one main.go that calls Run; no copy-pasted pipeline.
|
||||||
|
//
|
||||||
|
// Per docs/SPEC.md §3.4 component 1 (corpus builders): this is the
|
||||||
|
// substrate the rest of the matrix indexer's value depends on. Get
|
||||||
|
// the pipeline right, then iterate on builders.
|
||||||
|
package corpusingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Row is one logical document in a corpus. Metadata may be any
|
||||||
|
// JSON-marshalable value (struct, map, json.RawMessage); the library
|
||||||
|
// marshals once per row before pushing to vectord.
|
||||||
|
type Row struct {
|
||||||
|
ID string
|
||||||
|
Text string
|
||||||
|
Metadata any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source produces a stream of rows. Source lifecycle (open/close) is
|
||||||
|
// owned by the caller; this package only consumes Next() until io.EOF.
|
||||||
|
type Source interface {
|
||||||
|
// Next returns the next row or io.EOF when the source is drained.
|
||||||
|
// Other errors cause Run to abort with the error wrapped.
|
||||||
|
Next() (Row, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config drives one Run. Defaults match the Ollama-on-A4000 sweet
|
||||||
|
// spot from the 500K validation; override per-deployment if needed.
|
||||||
|
type Config struct {
|
||||||
|
GatewayURL string // default "http://127.0.0.1:3110"
|
||||||
|
IndexName string // required
|
||||||
|
Dimension int // required, must match the embed model output
|
||||||
|
Distance string // default "cosine"
|
||||||
|
EmbedModel string // optional; empty = embedd's default
|
||||||
|
EmbedBatch int // default 16, texts per /v1/embed call
|
||||||
|
EmbedWorkers int // default 8, parallel embed goroutines
|
||||||
|
AddBatch int // default 1000, items per /v1/vectors/index/add call
|
||||||
|
Limit int // 0 = no limit (process all rows)
|
||||||
|
DropExisting bool // true = DELETE index first; false = idempotent reuse
|
||||||
|
HTTPClient *http.Client
|
||||||
|
// LogProgress is the interval between progress logs. 0 disables.
|
||||||
|
LogProgress time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats reports run outcomes. FailedBatches counts embed-or-add
|
||||||
|
// batches that errored out and were skipped (partial-failure
|
||||||
|
// semantics). When non-zero, Run returns ErrPartialFailure so
|
||||||
|
// callers can't accidentally treat "1 of 313 batches succeeded"
|
||||||
|
// as a successful run.
|
||||||
|
type Stats struct {
|
||||||
|
Scanned int64
|
||||||
|
Embedded int64
|
||||||
|
Added int64
|
||||||
|
Wall time.Duration
|
||||||
|
FailedBatches int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPartialFailure signals that one or more batches errored during
|
||||||
|
// Run. Stats.FailedBatches has the count; the caller decides
|
||||||
|
// whether to retry / log / abort. Per 2026-04-29 cross-lineage
|
||||||
|
// scrum (Opus WARN): the original behavior returned nil even when
|
||||||
|
// 100% of batches failed silently, making "embedded=0/scanned=N"
|
||||||
|
// look like an empty corpus rather than a broken pipeline.
|
||||||
|
var ErrPartialFailure = errors.New("corpusingest: one or more batches failed")
|
||||||
|
|
||||||
|
// Run executes the ingest pipeline. Returns on source EOF after all
|
||||||
|
// in-flight jobs drain, on context cancellation, or on the first
|
||||||
|
// embed/add error (errors are logged via slog and the pipeline
|
||||||
|
// continues — partial-failure semantics; see comment inside).
|
||||||
|
func Run(ctx context.Context, cfg Config, src Source) (Stats, error) {
|
||||||
|
cfg = applyDefaults(cfg)
|
||||||
|
if err := validateConfig(cfg); err != nil {
|
||||||
|
return Stats{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t0 := time.Now()
|
||||||
|
if err := prepareIndex(ctx, cfg); err != nil {
|
||||||
|
return Stats{}, fmt.Errorf("prepare index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := make(chan job, cfg.EmbedWorkers*2)
|
||||||
|
|
||||||
|
var (
|
||||||
|
totalEmbedded int64
|
||||||
|
totalAdded int64
|
||||||
|
failedBatches int64
|
||||||
|
)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < cfg.EmbedWorkers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range jobs {
|
||||||
|
vecs, err := embedBatch(ctx, cfg, j.texts)
|
||||||
|
if err != nil {
|
||||||
|
// Partial-failure semantics: log + continue. A wedged
|
||||||
|
// embed batch shouldn't kill 8 workers' worth of
|
||||||
|
// progress; Run returns ErrPartialFailure on any
|
||||||
|
// failure so callers can't miss the signal.
|
||||||
|
slog.Warn("corpusingest: embed batch failed",
|
||||||
|
"index", cfg.IndexName, "items", len(j.texts), "err", err)
|
||||||
|
atomic.AddInt64(&failedBatches, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Defense against a degraded embed backend that returns
|
||||||
|
// fewer vectors than texts: vecs[i] would panic in
|
||||||
|
// addBatch otherwise. Caught by ContextCancel unit test.
|
||||||
|
if len(vecs) != len(j.ids) {
|
||||||
|
slog.Warn("corpusingest: embed returned wrong count",
|
||||||
|
"index", cfg.IndexName, "want", len(j.ids), "got", len(vecs))
|
||||||
|
atomic.AddInt64(&failedBatches, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
atomic.AddInt64(&totalEmbedded, int64(len(vecs)))
|
||||||
|
if err := addBatch(ctx, cfg, j.ids, vecs, j.metas); err != nil {
|
||||||
|
slog.Warn("corpusingest: add batch failed",
|
||||||
|
"index", cfg.IndexName, "items", len(j.ids), "err", err)
|
||||||
|
atomic.AddInt64(&failedBatches, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
atomic.AddInt64(&totalAdded, int64(len(j.ids)))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
stopProgress := make(chan struct{})
|
||||||
|
progressDone := make(chan struct{})
|
||||||
|
if cfg.LogProgress > 0 {
|
||||||
|
go func() {
|
||||||
|
defer close(progressDone)
|
||||||
|
ticker := time.NewTicker(cfg.LogProgress)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
slog.Info("corpusingest: progress",
|
||||||
|
"index", cfg.IndexName,
|
||||||
|
"embedded", atomic.LoadInt64(&totalEmbedded),
|
||||||
|
"added", atomic.LoadInt64(&totalAdded))
|
||||||
|
case <-stopProgress:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(progressDone)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanned, err := drainSource(ctx, cfg, src, jobs)
|
||||||
|
close(jobs)
|
||||||
|
wg.Wait()
|
||||||
|
close(stopProgress) // tell the progress goroutine to exit; would otherwise hang Run forever (caught by candidates e2e 2026-04-29)
|
||||||
|
<-progressDone
|
||||||
|
|
||||||
|
stats := Stats{
|
||||||
|
Scanned: scanned,
|
||||||
|
Embedded: atomic.LoadInt64(&totalEmbedded),
|
||||||
|
Added: atomic.LoadInt64(&totalAdded),
|
||||||
|
Wall: time.Since(t0),
|
||||||
|
FailedBatches: atomic.LoadInt64(&failedBatches),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
if stats.FailedBatches > 0 {
|
||||||
|
return stats, fmt.Errorf("%w: %d batches failed (embedded=%d added=%d scanned=%d)",
|
||||||
|
ErrPartialFailure, stats.FailedBatches, stats.Embedded, stats.Added, stats.Scanned)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainSource pulls rows, batches them, and dispatches into jobs.
|
||||||
|
// Returns when source EOFs, ctx cancels, or limit is hit.
|
||||||
|
func drainSource(ctx context.Context, cfg Config, src Source, jobs chan<- job) (int64, error) {
|
||||||
|
curIDs := make([]string, 0, cfg.EmbedBatch)
|
||||||
|
curTexts := make([]string, 0, cfg.EmbedBatch)
|
||||||
|
curMetas := make([]json.RawMessage, 0, cfg.EmbedBatch)
|
||||||
|
|
||||||
|
flush := func() {
|
||||||
|
if len(curIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobs <- job{ids: curIDs, texts: curTexts, metas: curMetas}
|
||||||
|
curIDs = make([]string, 0, cfg.EmbedBatch)
|
||||||
|
curTexts = make([]string, 0, cfg.EmbedBatch)
|
||||||
|
curMetas = make([]json.RawMessage, 0, cfg.EmbedBatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
var scanned int64
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
flush()
|
||||||
|
return scanned, ctx.Err()
|
||||||
|
}
|
||||||
|
row, err := src.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
flush()
|
||||||
|
return scanned, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
flush()
|
||||||
|
return scanned, fmt.Errorf("source row %d: %w", scanned, err)
|
||||||
|
}
|
||||||
|
if row.ID == "" {
|
||||||
|
return scanned, fmt.Errorf("source row %d: empty id", scanned)
|
||||||
|
}
|
||||||
|
// Empty Text would 400 at embedd; skip-with-warn rather than
|
||||||
|
// abort the whole run — a stray empty row shouldn't kill 500K.
|
||||||
|
if row.Text == "" {
|
||||||
|
slog.Warn("corpusingest: skipping row with empty text",
|
||||||
|
"index", cfg.IndexName, "id", row.ID)
|
||||||
|
scanned++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta, err := marshalMeta(row.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
return scanned, fmt.Errorf("row %s: marshal metadata: %w", row.ID, err)
|
||||||
|
}
|
||||||
|
curIDs = append(curIDs, row.ID)
|
||||||
|
curTexts = append(curTexts, row.Text)
|
||||||
|
curMetas = append(curMetas, meta)
|
||||||
|
scanned++
|
||||||
|
|
||||||
|
if len(curIDs) >= cfg.EmbedBatch {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
if cfg.Limit > 0 && scanned >= int64(cfg.Limit) {
|
||||||
|
flush()
|
||||||
|
return scanned, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// job is the unit of work between drainSource and the embed workers.
|
||||||
|
// Internal type; kept small so the channel buffer doesn't bloat.
|
||||||
|
type job struct {
|
||||||
|
ids []string
|
||||||
|
texts []string
|
||||||
|
metas []json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalMeta(v any) (json.RawMessage, error) {
|
||||||
|
if v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if rm, ok := v.(json.RawMessage); ok {
|
||||||
|
return rm, nil
|
||||||
|
}
|
||||||
|
return json.Marshal(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareIndex creates the vectord index, optionally dropping a
|
||||||
|
// preexisting one. Idempotent on matching params: 409 from create is
|
||||||
|
// treated as "already exists, reuse." If DropExisting is set, DELETE
|
||||||
|
// fires first to give a clean slate.
|
||||||
|
func prepareIndex(ctx context.Context, cfg Config) error {
|
||||||
|
if cfg.DropExisting {
|
||||||
|
if err := httpDelete(ctx, cfg.HTTPClient,
|
||||||
|
cfg.GatewayURL+"/v1/vectors/index/"+cfg.IndexName); err != nil {
|
||||||
|
// 404 (not found) is fine — drop-existing is idempotent.
|
||||||
|
slog.Debug("corpusingest: drop existing", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"name": cfg.IndexName,
|
||||||
|
"dimension": cfg.Dimension,
|
||||||
|
"distance": cfg.Distance,
|
||||||
|
})
|
||||||
|
code, msg, err := httpPost(ctx, cfg.HTTPClient, cfg.GatewayURL+"/v1/vectors/index", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case http.StatusCreated:
|
||||||
|
slog.Info("corpusingest: created index",
|
||||||
|
"name", cfg.IndexName, "dim", cfg.Dimension, "distance", cfg.Distance)
|
||||||
|
case http.StatusConflict:
|
||||||
|
// Already exists — vectord didn't change params on conflict.
|
||||||
|
// Caller's responsibility to ensure existing dim/distance match.
|
||||||
|
slog.Info("corpusingest: index already exists, reusing", "name", cfg.IndexName)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("create index %d: %s", code, msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func embedBatch(ctx context.Context, cfg Config, texts []string) ([][]float32, error) {
|
||||||
|
body := map[string]any{"texts": texts}
|
||||||
|
if cfg.EmbedModel != "" {
|
||||||
|
body["model"] = cfg.EmbedModel
|
||||||
|
}
|
||||||
|
bs, _ := json.Marshal(body)
|
||||||
|
code, msg, raw, err := httpPostRaw(ctx, cfg.HTTPClient, cfg.GatewayURL+"/v1/embed", bs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if code != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("embed status %d: %s", code, msg)
|
||||||
|
}
|
||||||
|
var er struct {
|
||||||
|
Vectors [][]float32 `json:"vectors"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &er); err != nil {
|
||||||
|
return nil, fmt.Errorf("embed decode: %w", err)
|
||||||
|
}
|
||||||
|
return er.Vectors, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addBatch(ctx context.Context, cfg Config, ids []string, vecs [][]float32, metas []json.RawMessage) error {
|
||||||
|
type addItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Vector []float32 `json:"vector"`
|
||||||
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
// Add-batch may exceed cfg.AddBatch when EmbedBatch divides into it
|
||||||
|
// non-evenly; vectord handles that fine. Keep one HTTP per job.
|
||||||
|
items := make([]addItem, len(ids))
|
||||||
|
for i := range ids {
|
||||||
|
items[i] = addItem{ID: ids[i], Vector: vecs[i], Metadata: metas[i]}
|
||||||
|
}
|
||||||
|
bs, _ := json.Marshal(map[string]any{"items": items})
|
||||||
|
code, msg, err := httpPost(ctx, cfg.HTTPClient,
|
||||||
|
cfg.GatewayURL+"/v1/vectors/index/"+cfg.IndexName+"/add", bs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if code != http.StatusOK {
|
||||||
|
return fmt.Errorf("add status %d: %s", code, msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP helpers — small, no extra deps ─────────────────────────
|
||||||
|
|
||||||
|
func httpPost(ctx context.Context, hc *http.Client, url string, body []byte) (int, string, error) {
|
||||||
|
code, msg, _, err := httpPostRaw(ctx, hc, url, body)
|
||||||
|
return code, msg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpPostRaw(ctx context.Context, hc *http.Client, url string, body []byte) (int, string, []byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp.StatusCode, "", nil, err
|
||||||
|
}
|
||||||
|
preview := raw
|
||||||
|
if len(preview) > 256 {
|
||||||
|
preview = preview[:256]
|
||||||
|
}
|
||||||
|
return resp.StatusCode, string(preview), raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpDelete(ctx context.Context, hc *http.Client, url string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||||
|
return fmt.Errorf("delete status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── config validation + defaults ────────────────────────────────
|
||||||
|
|
||||||
|
func applyDefaults(cfg Config) Config {
|
||||||
|
if cfg.GatewayURL == "" {
|
||||||
|
cfg.GatewayURL = "http://127.0.0.1:3110"
|
||||||
|
}
|
||||||
|
if cfg.Distance == "" {
|
||||||
|
cfg.Distance = "cosine"
|
||||||
|
}
|
||||||
|
if cfg.EmbedBatch <= 0 {
|
||||||
|
cfg.EmbedBatch = 16
|
||||||
|
}
|
||||||
|
if cfg.EmbedWorkers <= 0 {
|
||||||
|
cfg.EmbedWorkers = 8
|
||||||
|
}
|
||||||
|
if cfg.AddBatch <= 0 {
|
||||||
|
cfg.AddBatch = 1000
|
||||||
|
}
|
||||||
|
if cfg.HTTPClient == nil {
|
||||||
|
cfg.HTTPClient = &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
}
|
||||||
|
if cfg.LogProgress < 0 {
|
||||||
|
cfg.LogProgress = 0
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfig(cfg Config) error {
|
||||||
|
if cfg.IndexName == "" {
|
||||||
|
return errors.New("corpusingest: IndexName is required")
|
||||||
|
}
|
||||||
|
if cfg.Dimension <= 0 {
|
||||||
|
return errors.New("corpusingest: Dimension must be > 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
package corpusingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeGateway records the embed + add calls corpusingest fires and
|
||||||
|
// returns canned responses. The whole point of the unit test is to
|
||||||
|
// validate the pipeline shape (request payloads, batching, stats)
|
||||||
|
// without needing live embedd/vectord.
|
||||||
|
type fakeGateway struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
embedCalls int
|
||||||
|
embedTexts [][]string // texts per call
|
||||||
|
addCalls int
|
||||||
|
addItems [][]addItem // items per call
|
||||||
|
createCalled bool
|
||||||
|
deleteCalled bool
|
||||||
|
indexConflict bool // simulate "index already exists" → 409
|
||||||
|
embedDimension int
|
||||||
|
}
|
||||||
|
|
||||||
|
type addItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Vector []float32 `json:"vector"`
|
||||||
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeGateway(dim int) *fakeGateway {
|
||||||
|
return &fakeGateway{embedDimension: dim}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGateway) handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("/v1/vectors/index", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "wrong method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.createCalled = true
|
||||||
|
conflict := f.indexConflict
|
||||||
|
f.mu.Unlock()
|
||||||
|
if conflict {
|
||||||
|
http.Error(w, "exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/v1/embed", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Texts []string `json:"texts"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Synthesize deterministic vectors: vector[i] = float32(i+1).
|
||||||
|
vecs := make([][]float32, len(req.Texts))
|
||||||
|
for i := range vecs {
|
||||||
|
v := make([]float32, f.embedDimension)
|
||||||
|
for j := range v {
|
||||||
|
v[j] = float32(i + j + 1)
|
||||||
|
}
|
||||||
|
vecs[i] = v
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.embedCalls++
|
||||||
|
// Copy because we'll release the slice after returning.
|
||||||
|
texts := append([]string(nil), req.Texts...)
|
||||||
|
f.embedTexts = append(f.embedTexts, texts)
|
||||||
|
f.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"vectors": vecs,
|
||||||
|
"dimension": f.embedDimension,
|
||||||
|
"model": "fake-embed",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/v1/vectors/index/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// /v1/vectors/index/{name}/add
|
||||||
|
if !strings.HasSuffix(r.URL.Path, "/add") {
|
||||||
|
if r.Method == http.MethodDelete {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.deleteCalled = true
|
||||||
|
f.mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "unhandled "+r.URL.Path, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Items []addItem `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.addCalls++
|
||||||
|
f.addItems = append(f.addItems, append([]addItem(nil), req.Items...))
|
||||||
|
f.mu.Unlock()
|
||||||
|
_, _ = io.WriteString(w, `{"added":`+fmt.Sprint(len(req.Items))+`}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticSource yields a fixed slice of rows.
|
||||||
|
type staticSource struct {
|
||||||
|
rows []Row
|
||||||
|
i int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *staticSource) Next() (Row, error) {
|
||||||
|
if s.i >= len(s.rows) {
|
||||||
|
return Row{}, io.EOF
|
||||||
|
}
|
||||||
|
r := s.rows[s.i]
|
||||||
|
s.i++
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_PipelineShapeAndStats(t *testing.T) {
|
||||||
|
const dim = 4
|
||||||
|
fg := newFakeGateway(dim)
|
||||||
|
srv := httptest.NewServer(fg.handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
rows := make([]Row, 50)
|
||||||
|
for i := range rows {
|
||||||
|
rows[i] = Row{
|
||||||
|
ID: fmt.Sprintf("r-%03d", i),
|
||||||
|
Text: fmt.Sprintf("row %d text", i),
|
||||||
|
Metadata: map[string]any{"i": i, "kind": "test"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := Run(context.Background(), Config{
|
||||||
|
GatewayURL: srv.URL,
|
||||||
|
IndexName: "test_corpus",
|
||||||
|
Dimension: dim,
|
||||||
|
Distance: "cosine",
|
||||||
|
EmbedBatch: 16,
|
||||||
|
EmbedWorkers: 4,
|
||||||
|
HTTPClient: srv.Client(),
|
||||||
|
LogProgress: 0,
|
||||||
|
}, &staticSource{rows: rows})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.Scanned != 50 {
|
||||||
|
t.Errorf("Scanned: want 50, got %d", stats.Scanned)
|
||||||
|
}
|
||||||
|
if stats.Embedded != 50 {
|
||||||
|
t.Errorf("Embedded: want 50, got %d", stats.Embedded)
|
||||||
|
}
|
||||||
|
if stats.Added != 50 {
|
||||||
|
t.Errorf("Added: want 50, got %d", stats.Added)
|
||||||
|
}
|
||||||
|
if !fg.createCalled {
|
||||||
|
t.Error("expected create-index to be called")
|
||||||
|
}
|
||||||
|
// 50 rows / 16 batch = ceil(50/16) = 4 batches → 4 embed calls + 4 add calls
|
||||||
|
if fg.embedCalls != 4 {
|
||||||
|
t.Errorf("embedCalls: want 4 (50 rows / 16 batch), got %d", fg.embedCalls)
|
||||||
|
}
|
||||||
|
if fg.addCalls != 4 {
|
||||||
|
t.Errorf("addCalls: want 4, got %d", fg.addCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum of texts across embed calls must be 50, and IDs across add
|
||||||
|
// calls must be every r-NNN exactly once.
|
||||||
|
totalTexts := 0
|
||||||
|
for _, ts := range fg.embedTexts {
|
||||||
|
totalTexts += len(ts)
|
||||||
|
}
|
||||||
|
if totalTexts != 50 {
|
||||||
|
t.Errorf("total embedded texts: want 50, got %d", totalTexts)
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, items := range fg.addItems {
|
||||||
|
for _, it := range items {
|
||||||
|
if seen[it.ID] {
|
||||||
|
t.Errorf("duplicate id in add stream: %s", it.ID)
|
||||||
|
}
|
||||||
|
seen[it.ID] = true
|
||||||
|
if len(it.Vector) != dim {
|
||||||
|
t.Errorf("vector dim: want %d, got %d", dim, len(it.Vector))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seen) != 50 {
|
||||||
|
t.Errorf("unique ids added: want 50, got %d", len(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_DropExistingFiresDelete(t *testing.T) {
|
||||||
|
fg := newFakeGateway(4)
|
||||||
|
srv := httptest.NewServer(fg.handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), Config{
|
||||||
|
GatewayURL: srv.URL,
|
||||||
|
IndexName: "drops_first",
|
||||||
|
Dimension: 4,
|
||||||
|
DropExisting: true,
|
||||||
|
HTTPClient: srv.Client(),
|
||||||
|
}, &staticSource{rows: []Row{{ID: "x", Text: "y", Metadata: nil}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if !fg.deleteCalled {
|
||||||
|
t.Error("expected delete-index to fire when DropExisting=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_IndexAlreadyExistsIsReused(t *testing.T) {
|
||||||
|
fg := newFakeGateway(4)
|
||||||
|
fg.indexConflict = true // first POST /v1/vectors/index → 409
|
||||||
|
srv := httptest.NewServer(fg.handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
stats, err := Run(context.Background(), Config{
|
||||||
|
GatewayURL: srv.URL,
|
||||||
|
IndexName: "exists_already",
|
||||||
|
Dimension: 4,
|
||||||
|
HTTPClient: srv.Client(),
|
||||||
|
EmbedWorkers: 1,
|
||||||
|
}, &staticSource{rows: []Row{{ID: "x", Text: "y", Metadata: nil}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run with existing index should succeed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.Added != 1 {
|
||||||
|
t.Errorf("Added: want 1, got %d", stats.Added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_LimitStopsEarly(t *testing.T) {
|
||||||
|
fg := newFakeGateway(4)
|
||||||
|
srv := httptest.NewServer(fg.handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
rows := make([]Row, 100)
|
||||||
|
for i := range rows {
|
||||||
|
rows[i] = Row{ID: fmt.Sprintf("r-%d", i), Text: "t", Metadata: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := Run(context.Background(), Config{
|
||||||
|
GatewayURL: srv.URL,
|
||||||
|
IndexName: "limited",
|
||||||
|
Dimension: 4,
|
||||||
|
Limit: 25,
|
||||||
|
EmbedBatch: 8,
|
||||||
|
EmbedWorkers: 2,
|
||||||
|
HTTPClient: srv.Client(),
|
||||||
|
}, &staticSource{rows: rows})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if stats.Scanned != 25 {
|
||||||
|
t.Errorf("Scanned: want 25 (limit), got %d", stats.Scanned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_EmptyTextSkipped(t *testing.T) {
|
||||||
|
fg := newFakeGateway(4)
|
||||||
|
srv := httptest.NewServer(fg.handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
rows := []Row{
|
||||||
|
{ID: "a", Text: "real text", Metadata: nil},
|
||||||
|
{ID: "b", Text: "", Metadata: nil}, // skipped
|
||||||
|
{ID: "c", Text: "more text", Metadata: nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := Run(context.Background(), Config{
|
||||||
|
GatewayURL: srv.URL, IndexName: "skip", Dimension: 4,
|
||||||
|
HTTPClient: srv.Client(),
|
||||||
|
}, &staticSource{rows: rows})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if stats.Scanned != 3 {
|
||||||
|
t.Errorf("Scanned: want 3 (b is skipped but counted as scanned), got %d", stats.Scanned)
|
||||||
|
}
|
||||||
|
if stats.Added != 2 {
|
||||||
|
t.Errorf("Added: want 2 (b excluded from embed), got %d", stats.Added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_ProgressLoggerExits guards the bug caught 2026-04-29 in
|
||||||
|
// the candidates e2e: when LogProgress > 0, the progress goroutine's
|
||||||
|
// only exit was ctx.Done(). With context.Background() in the
|
||||||
|
// production driver, Run hung forever after the pipeline finished.
|
||||||
|
// This test bounds Run's wall to a few hundred ms — if it regresses,
|
||||||
|
// the test deadline kicks in.
|
||||||
|
func TestRun_ProgressLoggerExits(t *testing.T) {
|
||||||
|
fg := newFakeGateway(4)
|
||||||
|
srv := httptest.NewServer(fg.handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
rows := []Row{
|
||||||
|
{ID: "a", Text: "x", Metadata: nil},
|
||||||
|
{ID: "b", Text: "y", Metadata: nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := Run(context.Background(), Config{
|
||||||
|
GatewayURL: srv.URL,
|
||||||
|
IndexName: "progress_test",
|
||||||
|
Dimension: 4,
|
||||||
|
HTTPClient: srv.Client(),
|
||||||
|
LogProgress: 50 * time.Millisecond,
|
||||||
|
}, &staticSource{rows: rows})
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Run did not return within 2s — progress goroutine likely hanging")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_NonzeroFailedBatchesReturnsError guards the 2026-04-29
|
||||||
|
// scrum WARN: original behavior returned nil even when 100% of
|
||||||
|
// batches failed, making "embedded=0/scanned=N" look like an empty
|
||||||
|
// corpus rather than a broken pipeline.
|
||||||
|
func TestRun_NonzeroFailedBatchesReturnsError(t *testing.T) {
|
||||||
|
// Fake gateway that fails every embed call.
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/v1/vectors/index", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/v1/embed", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "embed failure injected", http.StatusBadGateway)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/v1/vectors/index/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// shouldn't reach here since embed fails first
|
||||||
|
http.Error(w, "should not be called", http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
rows := make([]Row, 5)
|
||||||
|
for i := range rows {
|
||||||
|
rows[i] = Row{ID: fmt.Sprintf("r-%d", i), Text: "x"}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := Run(context.Background(), Config{
|
||||||
|
GatewayURL: srv.URL, IndexName: "fail_only", Dimension: 4,
|
||||||
|
EmbedBatch: 1, EmbedWorkers: 1, HTTPClient: srv.Client(),
|
||||||
|
}, &staticSource{rows: rows})
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrPartialFailure) {
|
||||||
|
t.Errorf("want ErrPartialFailure, got %v", err)
|
||||||
|
}
|
||||||
|
if stats.FailedBatches == 0 {
|
||||||
|
t.Error("FailedBatches should be > 0 when embeds fail")
|
||||||
|
}
|
||||||
|
if stats.Added != 0 {
|
||||||
|
t.Errorf("Added: want 0 (all failed), got %d", stats.Added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_RequiresIndexName(t *testing.T) {
|
||||||
|
_, err := Run(context.Background(), Config{Dimension: 4},
|
||||||
|
&staticSource{rows: nil})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "IndexName") {
|
||||||
|
t.Errorf("want IndexName-required error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_RequiresDimension(t *testing.T) {
|
||||||
|
_, err := Run(context.Background(), Config{IndexName: "x"},
|
||||||
|
&staticSource{rows: nil})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "Dimension") {
|
||||||
|
t.Errorf("want Dimension-required error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_ContextCancel verifies the pipeline drains cleanly when
|
||||||
|
// ctx is cancelled mid-run. Source returns rows fast enough that
|
||||||
|
// without ctx the run would complete; cancelling early should stop
|
||||||
|
// well before all 1000 rows are processed.
|
||||||
|
func TestRun_ContextCancel(t *testing.T) {
|
||||||
|
fg := newFakeGateway(4)
|
||||||
|
// Slow embed handler: each call sleeps 50ms.
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/v1/vectors/index", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/v1/embed", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Texts []string `json:"texts"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
// Simulate slow-but-valid backend so we test ctx cancel, not
|
||||||
|
// degraded-payload handling (that's covered in production by
|
||||||
|
// the len-mismatch guard in Run's worker).
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
_ = fg
|
||||||
|
vecs := make([][]float32, len(req.Texts))
|
||||||
|
for i := range vecs {
|
||||||
|
vecs[i] = []float32{1, 2, 3, 4}
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"vectors": vecs,
|
||||||
|
"dimension": 4,
|
||||||
|
"model": "x",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/v1/vectors/index/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, `{}`)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
rows := make([]Row, 1000)
|
||||||
|
for i := range rows {
|
||||||
|
rows[i] = Row{ID: fmt.Sprintf("r-%d", i), Text: "t"}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stats, err := Run(ctx, Config{
|
||||||
|
GatewayURL: srv.URL, IndexName: "cancel_me", Dimension: 4,
|
||||||
|
EmbedBatch: 1, EmbedWorkers: 1, HTTPClient: srv.Client(),
|
||||||
|
}, &staticSource{rows: rows})
|
||||||
|
// Either an error or a partial stats; the point is "didn't process all 1000."
|
||||||
|
if stats.Scanned >= 1000 {
|
||||||
|
t.Errorf("ctx cancel did not stop early: scanned=%d err=%v", stats.Scanned, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package distillation
|
||||||
|
|
||||||
|
// Audit-baseline lineage — the longitudinal signal that distinguishes
|
||||||
|
// "metrics shifted because the world changed" from "metrics shifted
|
||||||
|
// because we broke something." Mirrors the Rust audit_full.ts
|
||||||
|
// LoadBaseline/AppendBaseline/buildDriftTable shape so a Go-side
|
||||||
|
// audit run can be compared against Rust-side baselines and
|
||||||
|
// vice-versa during the migration.
|
||||||
|
//
|
||||||
|
// Storage: data/_kb/audit_baselines.jsonl, one AuditBaseline per
|
||||||
|
// line, append-only. The LAST line is the most recent. New runs
|
||||||
|
// read the prior baseline, compute drift vs current metrics, then
|
||||||
|
// append a fresh entry.
|
||||||
|
//
|
||||||
|
// Why generic on metric name (vs Rust's pinned p2_evidence_rows
|
||||||
|
// etc.): the Rust phase numbering (p0..p7) doesn't translate to Go
|
||||||
|
// directly. Operators with mixed Rust+Go pipelines should use the
|
||||||
|
// SAME metric names on both sides so the drift table compares
|
||||||
|
// like-for-like. Helper constants below pin the Rust-compat names
|
||||||
|
// for callers running both runtimes.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditBaseline is one entry in the audit_baselines.jsonl
|
||||||
|
// longitudinal log. Schema-stable; new metrics land as new keys
|
||||||
|
// in the Metrics map (additive — readers tolerate unknown keys).
|
||||||
|
type AuditBaseline struct {
|
||||||
|
RecordedAt string `json:"recorded_at"` // ISO 8601 UTC
|
||||||
|
GitCommit string `json:"git_commit,omitempty"` // sha of the run's HEAD
|
||||||
|
Metrics map[string]int64 `json:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditBaselineRustCompat lists the metric names the Rust pipeline
|
||||||
|
// emits at audit_full.ts. Go-side callers running an equivalent
|
||||||
|
// audit should use these names so drift compares across runtimes.
|
||||||
|
// Adding new names here requires the Rust side to mint them too.
|
||||||
|
var AuditBaselineRustCompat = []string{
|
||||||
|
"p2_evidence_rows",
|
||||||
|
"p2_evidence_skips",
|
||||||
|
"p3_accepted",
|
||||||
|
"p3_partial",
|
||||||
|
"p3_rejected",
|
||||||
|
"p3_human",
|
||||||
|
"p4_rag_rows",
|
||||||
|
"p4_sft_rows",
|
||||||
|
"p4_pref_pairs",
|
||||||
|
"p4_total_quarantined",
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultBaselinePath returns the canonical audit baselines path
|
||||||
|
// rooted at the lakehouse data dir. Match Rust's BASELINE_PATH_FOR.
|
||||||
|
func DefaultBaselinePath(root string) string {
|
||||||
|
return filepath.Join(root, "data", "_kb", "audit_baselines.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadLastBaseline reads audit_baselines.jsonl and returns the
|
||||||
|
// most recent entry — i.e. the LAST non-empty JSON line. Missing
|
||||||
|
// file or empty file returns (nil, nil), not an error: a fresh
|
||||||
|
// pipeline has no baseline yet, and the caller should treat that
|
||||||
|
// as "first run" via BuildAuditDriftTable's nil-prior handling.
|
||||||
|
//
|
||||||
|
// Malformed last line returns an error (rather than silently
|
||||||
|
// skipping to the previous line) so operators don't lose drift
|
||||||
|
// signal under partial-write corruption.
|
||||||
|
func LoadLastBaseline(path string) (*AuditBaseline, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read baselines: %w", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
// Walk back to the last non-empty line.
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
s := strings.TrimSpace(lines[i])
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var b AuditBaseline
|
||||||
|
if err := json.Unmarshal([]byte(s), &b); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode last baseline (line %d): %w", i+1, err)
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendBaseline appends one AuditBaseline as a JSON line to
|
||||||
|
// audit_baselines.jsonl. Creates the parent directory if missing.
|
||||||
|
// Atomic write at the line level: a partial write on disk-full or
|
||||||
|
// crash leaves the file with at most one truncated trailing line,
|
||||||
|
// which LoadLastBaseline will surface as a decode error.
|
||||||
|
func AppendBaseline(path string, b AuditBaseline) error {
|
||||||
|
if b.RecordedAt == "" {
|
||||||
|
return errors.New("audit_baseline: RecordedAt required")
|
||||||
|
}
|
||||||
|
if b.Metrics == nil {
|
||||||
|
return errors.New("audit_baseline: Metrics required (use empty map for zero-metric run)")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir baseline dir: %w", err)
|
||||||
|
}
|
||||||
|
line, err := json.Marshal(b)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode baseline: %w", err)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open baselines: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w := bufio.NewWriter(f)
|
||||||
|
if _, err := w.Write(line); err != nil {
|
||||||
|
return fmt.Errorf("write baseline: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.WriteByte('\n'); err != nil {
|
||||||
|
return fmt.Errorf("write newline: %w", err)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditDriftFlag categorizes a single metric's drift verdict.
|
||||||
|
// Mirrors the Rust DriftRow.flag values exactly.
|
||||||
|
type AuditDriftFlag string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuditDriftFlagFirstRun AuditDriftFlag = "first_run" // no prior baseline → can't compute change
|
||||||
|
AuditDriftFlagOK AuditDriftFlag = "ok" // |Δ%| ≤ threshold
|
||||||
|
AuditDriftFlagWarn AuditDriftFlag = "warn" // |Δ%| > threshold
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultDriftWarnThreshold is 20% — matches Rust's hard-coded
|
||||||
|
// `Math.abs(pct) > 0.20`. Operators tuning sensitivity per metric
|
||||||
|
// can pass a different value to BuildAuditDriftTable.
|
||||||
|
const DefaultDriftWarnThreshold = 0.20
|
||||||
|
|
||||||
|
// AuditDriftRow is one metric's drift verdict. PctChange is nil
|
||||||
|
// when prior baseline was zero (division-by-zero) OR when this is
|
||||||
|
// the first run. Encoded as *float64 so JSON omits the field
|
||||||
|
// rather than emitting 0.0 for "unknowable" cases.
|
||||||
|
type AuditDriftRow struct {
|
||||||
|
Metric string `json:"metric"`
|
||||||
|
Baseline *int64 `json:"baseline"`
|
||||||
|
Current int64 `json:"current"`
|
||||||
|
PctChange *float64 `json:"pct_change"`
|
||||||
|
Flag AuditDriftFlag `json:"flag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAuditDriftTable computes per-metric drift between a prior
|
||||||
|
// baseline (nil = first run) and the current metric snapshot. The
|
||||||
|
// result is sorted by metric name for stable display.
|
||||||
|
//
|
||||||
|
// Threshold is the absolute percent-change above which a metric is
|
||||||
|
// flagged "warn". Pass DefaultDriftWarnThreshold (0.20 = 20%) to
|
||||||
|
// match Rust audit_full.ts. Use a per-metric threshold map by
|
||||||
|
// calling BuildAuditDriftTable once per metric subset.
|
||||||
|
func BuildAuditDriftTable(prior *AuditBaseline, current map[string]int64, threshold float64) []AuditDriftRow {
|
||||||
|
if threshold <= 0 {
|
||||||
|
threshold = DefaultDriftWarnThreshold
|
||||||
|
}
|
||||||
|
// Union of metric names so a metric that disappeared from
|
||||||
|
// current still surfaces as "current=0, drifted -100%".
|
||||||
|
names := make(map[string]struct{}, len(current))
|
||||||
|
for k := range current {
|
||||||
|
names[k] = struct{}{}
|
||||||
|
}
|
||||||
|
if prior != nil {
|
||||||
|
for k := range prior.Metrics {
|
||||||
|
names[k] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows := make([]AuditDriftRow, 0, len(names))
|
||||||
|
for name := range names {
|
||||||
|
row := AuditDriftRow{Metric: name, Current: current[name]}
|
||||||
|
if prior == nil {
|
||||||
|
row.Flag = AuditDriftFlagFirstRun
|
||||||
|
rows = append(rows, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
priorVal, hadPrior := prior.Metrics[name]
|
||||||
|
if !hadPrior {
|
||||||
|
// New metric in current — treat as first-run for THIS metric.
|
||||||
|
row.Flag = AuditDriftFlagFirstRun
|
||||||
|
rows = append(rows, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row.Baseline = &priorVal
|
||||||
|
if priorVal == 0 {
|
||||||
|
// Division-by-zero: leave PctChange nil. If current is
|
||||||
|
// also 0 → ok (no change). Otherwise → warn (the metric
|
||||||
|
// went from zero to non-zero, which is always notable).
|
||||||
|
if current[name] == 0 {
|
||||||
|
row.Flag = AuditDriftFlagOK
|
||||||
|
} else {
|
||||||
|
row.Flag = AuditDriftFlagWarn
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pct := float64(current[name]-priorVal) / float64(priorVal)
|
||||||
|
row.PctChange = &pct
|
||||||
|
if math.Abs(pct) > threshold {
|
||||||
|
row.Flag = AuditDriftFlagWarn
|
||||||
|
} else {
|
||||||
|
row.Flag = AuditDriftFlagOK
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
// Sort for stable display + deterministic JSON output. Per
|
||||||
|
// scrum post_role_gate_v1 (Opus INFO): use sort.Slice — already
|
||||||
|
// imported in the package, idiomatic, and shorter.
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Metric < rows[j].Metric })
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatAuditDriftTable renders a drift table as a fixed-width
|
||||||
|
// text grid — useful for stdout dumps in audit-full runs. Matches
|
||||||
|
// the Rust output shape so an operator can grep across runtimes
|
||||||
|
// without re-learning the layout.
|
||||||
|
func FormatAuditDriftTable(rows []AuditDriftRow) string {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return "(no metrics)\n"
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
fmt.Fprintf(&buf, "%-26s %12s %12s %10s %s\n", "metric", "baseline", "current", "Δ%", "flag")
|
||||||
|
for _, r := range rows {
|
||||||
|
baseline := "-"
|
||||||
|
if r.Baseline != nil {
|
||||||
|
baseline = fmt.Sprintf("%d", *r.Baseline)
|
||||||
|
}
|
||||||
|
pct := "-"
|
||||||
|
if r.PctChange != nil {
|
||||||
|
pct = fmt.Sprintf("%+.1f%%", *r.PctChange*100)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&buf, "%-26s %12s %12d %10s %s\n",
|
||||||
|
r.Metric, baseline, r.Current, pct, r.Flag)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package distillation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLoadLastBaseline_MissingFile: no baselines file yet → (nil, nil),
|
||||||
|
// not an error. First-run pipelines have no baseline.
|
||||||
|
func TestLoadLastBaseline_MissingFile(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
got, err := LoadLastBaseline(filepath.Join(tmp, "nonexistent.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error on missing file, got %v", err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("expected nil baseline on missing file, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadLastBaseline_LastLineWins locks the "last line is most
|
||||||
|
// recent" semantic — append-only log read backward.
|
||||||
|
func TestLoadLastBaseline_LastLineWins(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "baselines.jsonl")
|
||||||
|
jsonl := `{"recorded_at":"2026-04-01T00:00:00Z","metrics":{"a":1}}
|
||||||
|
{"recorded_at":"2026-04-15T00:00:00Z","metrics":{"a":2}}
|
||||||
|
{"recorded_at":"2026-04-30T00:00:00Z","metrics":{"a":3}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(jsonl), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
got, err := LoadLastBaseline(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLastBaseline: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil || got.RecordedAt != "2026-04-30T00:00:00Z" {
|
||||||
|
t.Errorf("expected last-line baseline (2026-04-30), got %+v", got)
|
||||||
|
}
|
||||||
|
if got.Metrics["a"] != 3 {
|
||||||
|
t.Errorf("expected metrics.a=3, got %d", got.Metrics["a"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadLastBaseline_TolerateTrailingBlankLines: writers append
|
||||||
|
// "\n" — a stray blank line at the end mustn't trigger malformed-
|
||||||
|
// JSON errors.
|
||||||
|
func TestLoadLastBaseline_TolerateTrailingBlankLines(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "baselines.jsonl")
|
||||||
|
jsonl := `{"recorded_at":"2026-04-30T00:00:00Z","metrics":{"a":3}}
|
||||||
|
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(jsonl), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
got, err := LoadLastBaseline(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLastBaseline: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil || got.Metrics["a"] != 3 {
|
||||||
|
t.Errorf("expected baseline with metrics.a=3, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadLastBaseline_MalformedLastLineErrors: partial-write on
|
||||||
|
// disk-full would leave a truncated last line. Rather than
|
||||||
|
// silently skip back to a stale baseline, surface the error so
|
||||||
|
// operators don't lose drift signal.
|
||||||
|
func TestLoadLastBaseline_MalformedLastLineErrors(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "baselines.jsonl")
|
||||||
|
jsonl := `{"recorded_at":"2026-04-15T00:00:00Z","metrics":{"a":2}}
|
||||||
|
{"recorded_at":"2026-04-30T0` // truncated
|
||||||
|
if err := os.WriteFile(path, []byte(jsonl), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
_, err := LoadLastBaseline(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected decode error on truncated last line, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppendBaseline_RoundTrip: append one + read back via
|
||||||
|
// LoadLastBaseline. Critical for the "first audit-full run on a
|
||||||
|
// fresh box" path.
|
||||||
|
func TestAppendBaseline_RoundTrip(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "data", "_kb", "audit_baselines.jsonl")
|
||||||
|
b := AuditBaseline{
|
||||||
|
RecordedAt: "2026-05-01T12:00:00Z",
|
||||||
|
GitCommit: "deadbeef",
|
||||||
|
Metrics: map[string]int64{
|
||||||
|
"p3_accepted": 42,
|
||||||
|
"p4_sft_rows": 17,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := AppendBaseline(path, b); err != nil {
|
||||||
|
t.Fatalf("AppendBaseline: %v", err)
|
||||||
|
}
|
||||||
|
got, err := LoadLastBaseline(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLastBaseline: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("expected non-nil baseline after append")
|
||||||
|
}
|
||||||
|
if got.RecordedAt != b.RecordedAt || got.GitCommit != b.GitCommit {
|
||||||
|
t.Errorf("baseline header mismatch:\n got: %+v\n want: %+v", got, b)
|
||||||
|
}
|
||||||
|
if got.Metrics["p3_accepted"] != 42 || got.Metrics["p4_sft_rows"] != 17 {
|
||||||
|
t.Errorf("metrics roundtrip: got %+v", got.Metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppendBaseline_AppendsToExisting: existing file gets an
|
||||||
|
// extra line, prior contents preserved.
|
||||||
|
func TestAppendBaseline_AppendsToExisting(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "baselines.jsonl")
|
||||||
|
if err := os.WriteFile(path,
|
||||||
|
[]byte(`{"recorded_at":"2026-04-30T00:00:00Z","metrics":{"a":1}}`+"\n"),
|
||||||
|
0o644); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if err := AppendBaseline(path, AuditBaseline{
|
||||||
|
RecordedAt: "2026-05-01T00:00:00Z",
|
||||||
|
Metrics: map[string]int64{"a": 2},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AppendBaseline: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Errorf("expected 2 lines after append, got %d", len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppendBaseline_RejectsEmptyRecordedAt: schema invariant —
|
||||||
|
// every entry must carry a timestamp for ordering.
|
||||||
|
func TestAppendBaseline_RejectsEmptyRecordedAt(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "baselines.jsonl")
|
||||||
|
err := AppendBaseline(path, AuditBaseline{Metrics: map[string]int64{"a": 1}})
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error on empty RecordedAt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAuditDriftTable_FirstRun: no prior → every metric flagged
|
||||||
|
// "first_run", no PctChange.
|
||||||
|
func TestBuildAuditDriftTable_FirstRun(t *testing.T) {
|
||||||
|
rows := BuildAuditDriftTable(nil, map[string]int64{"a": 10, "b": 20}, 0)
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Flag != AuditDriftFlagFirstRun {
|
||||||
|
t.Errorf("metric %s: expected first_run flag, got %s", r.Metric, r.Flag)
|
||||||
|
}
|
||||||
|
if r.PctChange != nil {
|
||||||
|
t.Errorf("metric %s: PctChange should be nil on first run", r.Metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAuditDriftTable_ThresholdBoundary: exactly threshold = OK,
|
||||||
|
// just over = WARN. Locks the >|threshold| (strict) semantic.
|
||||||
|
func TestBuildAuditDriftTable_ThresholdBoundary(t *testing.T) {
|
||||||
|
prior := &AuditBaseline{Metrics: map[string]int64{"a": 100, "b": 100}}
|
||||||
|
current := map[string]int64{
|
||||||
|
"a": 120, // +20% — exactly at threshold → OK
|
||||||
|
"b": 121, // +21% — over threshold → WARN
|
||||||
|
}
|
||||||
|
rows := BuildAuditDriftTable(prior, current, 0.20)
|
||||||
|
byMetric := map[string]AuditDriftRow{}
|
||||||
|
for _, r := range rows {
|
||||||
|
byMetric[r.Metric] = r
|
||||||
|
}
|
||||||
|
if byMetric["a"].Flag != AuditDriftFlagOK {
|
||||||
|
t.Errorf("metric a (+20%% exactly): expected OK, got %s", byMetric["a"].Flag)
|
||||||
|
}
|
||||||
|
if byMetric["b"].Flag != AuditDriftFlagWarn {
|
||||||
|
t.Errorf("metric b (+21%%): expected warn, got %s", byMetric["b"].Flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAuditDriftTable_ZeroBaseline: prior=0 means we can't
|
||||||
|
// compute pct (div-by-0). PctChange stays nil; current=0 stays
|
||||||
|
// OK; current>0 escalates to WARN (zero→nonzero is always
|
||||||
|
// notable).
|
||||||
|
func TestBuildAuditDriftTable_ZeroBaseline(t *testing.T) {
|
||||||
|
prior := &AuditBaseline{Metrics: map[string]int64{"stayed_zero": 0, "went_nonzero": 0}}
|
||||||
|
current := map[string]int64{
|
||||||
|
"stayed_zero": 0,
|
||||||
|
"went_nonzero": 5,
|
||||||
|
}
|
||||||
|
rows := BuildAuditDriftTable(prior, current, 0.20)
|
||||||
|
byMetric := map[string]AuditDriftRow{}
|
||||||
|
for _, r := range rows {
|
||||||
|
byMetric[r.Metric] = r
|
||||||
|
}
|
||||||
|
if byMetric["stayed_zero"].Flag != AuditDriftFlagOK {
|
||||||
|
t.Errorf("0→0 should be OK, got %s", byMetric["stayed_zero"].Flag)
|
||||||
|
}
|
||||||
|
if byMetric["went_nonzero"].Flag != AuditDriftFlagWarn {
|
||||||
|
t.Errorf("0→5 should be warn, got %s", byMetric["went_nonzero"].Flag)
|
||||||
|
}
|
||||||
|
if byMetric["stayed_zero"].PctChange != nil || byMetric["went_nonzero"].PctChange != nil {
|
||||||
|
t.Errorf("zero-baseline rows must have nil PctChange (no division by zero)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAuditDriftTable_NewMetricInCurrent: a metric present in
|
||||||
|
// current but not in prior is flagged first_run, not "0%-change".
|
||||||
|
func TestBuildAuditDriftTable_NewMetricInCurrent(t *testing.T) {
|
||||||
|
prior := &AuditBaseline{Metrics: map[string]int64{"old_only": 5}}
|
||||||
|
current := map[string]int64{"old_only": 5, "brand_new": 10}
|
||||||
|
rows := BuildAuditDriftTable(prior, current, 0)
|
||||||
|
byMetric := map[string]AuditDriftRow{}
|
||||||
|
for _, r := range rows {
|
||||||
|
byMetric[r.Metric] = r
|
||||||
|
}
|
||||||
|
if byMetric["brand_new"].Flag != AuditDriftFlagFirstRun {
|
||||||
|
t.Errorf("new metric should be first_run, got %s", byMetric["brand_new"].Flag)
|
||||||
|
}
|
||||||
|
if byMetric["old_only"].Flag != AuditDriftFlagOK {
|
||||||
|
t.Errorf("unchanged metric should be OK, got %s", byMetric["old_only"].Flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAuditDriftTable_SortedByMetric: deterministic JSON
|
||||||
|
// output requires stable sort — drift tables in CI runs need to
|
||||||
|
// diff cleanly.
|
||||||
|
func TestBuildAuditDriftTable_SortedByMetric(t *testing.T) {
|
||||||
|
prior := &AuditBaseline{Metrics: map[string]int64{"zoo": 1, "alpha": 1, "midway": 1}}
|
||||||
|
current := map[string]int64{"zoo": 1, "alpha": 1, "midway": 1}
|
||||||
|
rows := BuildAuditDriftTable(prior, current, 0)
|
||||||
|
want := []string{"alpha", "midway", "zoo"}
|
||||||
|
for i, r := range rows {
|
||||||
|
if r.Metric != want[i] {
|
||||||
|
t.Errorf("rows[%d]: got %q, want %q", i, r.Metric, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatAuditDriftTable_RendersFlags: stdout dump shape — we
|
||||||
|
// don't pin every byte but verify the metric names + flags appear.
|
||||||
|
func TestFormatAuditDriftTable_RendersFlags(t *testing.T) {
|
||||||
|
rows := []AuditDriftRow{
|
||||||
|
{Metric: "p3_accepted", Current: 50, Flag: AuditDriftFlagFirstRun},
|
||||||
|
{Metric: "p4_sft_rows", Baseline: int64Ptr(10), Current: 13, PctChange: float64Ptr(0.30), Flag: AuditDriftFlagWarn},
|
||||||
|
}
|
||||||
|
out := FormatAuditDriftTable(rows)
|
||||||
|
for _, want := range []string{"p3_accepted", "p4_sft_rows", "first_run", "warn", "+30.0%"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("expected %q in output:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatAuditDriftTable_EmptyHeader: empty rows yields a
|
||||||
|
// single-line "(no metrics)" — operators see something instead of
|
||||||
|
// blank output.
|
||||||
|
func TestFormatAuditDriftTable_EmptyHeader(t *testing.T) {
|
||||||
|
out := FormatAuditDriftTable(nil)
|
||||||
|
if !strings.Contains(out, "no metrics") {
|
||||||
|
t.Errorf("expected 'no metrics' notice on empty input, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Ptr(v int64) *int64 { return &v }
|
||||||
|
func float64Ptr(v float64) *float64 { return &v }
|
||||||
@@ -0,0 +1,807 @@
|
|||||||
|
package distillation
|
||||||
|
|
||||||
|
// Audit-FULL pipeline — Go port of scripts/distillation/audit_full.ts
|
||||||
|
// (Rust legacy). Runs the metric-collection passes that produce
|
||||||
|
// audit_baselines.jsonl entries. Pure observability: never modifies
|
||||||
|
// pipeline data, only reads and tallies.
|
||||||
|
//
|
||||||
|
// Phase coverage in this port:
|
||||||
|
// - Phase 0 (file presence) ✓ ported
|
||||||
|
// - Phase 1 (schema validators) ✓ ported (invokes `go test`
|
||||||
|
// on internal/distillation)
|
||||||
|
// - Phase 2 (evidence materialization) ✓ ported as observer — reads
|
||||||
|
// existing data/evidence/
|
||||||
|
// and tallies rows. Doesn't
|
||||||
|
// re-run the materializer
|
||||||
|
// (which is Rust-side); the
|
||||||
|
// audit-FULL discipline is
|
||||||
|
// OBSERVATION, not re-execution.
|
||||||
|
// - Phase 3 (scored-runs distribution) ✓ ported
|
||||||
|
// - Phase 4 (contamination firewall) ✓ ported
|
||||||
|
// - Phase 5 (receipts validation) ✓ ported as observer — reads
|
||||||
|
// reports/distillation/{run_id}/
|
||||||
|
// summary.json + 5 stage
|
||||||
|
// receipts (any-runtime artifacts).
|
||||||
|
// - Phase 6 (acceptance gate) ✗ skipped — TS-only fixture
|
||||||
|
// harness at scripts/distillation/
|
||||||
|
// acceptance.ts with bun-
|
||||||
|
// specific deps. Porting the
|
||||||
|
// fixtures + invariant runner
|
||||||
|
// to Go is its own ADR-worth
|
||||||
|
// of work; out of scope.
|
||||||
|
// - Phase 7 (replay log shape) ✓ ported as observer — reads
|
||||||
|
// data/_kb/replay_runs.jsonl
|
||||||
|
// and checks shape, doesn't
|
||||||
|
// re-run replay (Rust-side
|
||||||
|
// replay.ts is the producer).
|
||||||
|
//
|
||||||
|
// Output: a structured PhaseCheckReport plus a Markdown summary.
|
||||||
|
// Operators run this from cmd/audit_full to validate a Go-side
|
||||||
|
// distillation pipeline run produced sane outputs.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PhaseCheck is one observable check within a phase. Mirrors the
|
||||||
|
// Rust shape exactly — Markdown rendering uses the same column
|
||||||
|
// layout so cross-runtime diff'ing is meaningful.
|
||||||
|
type PhaseCheck struct {
|
||||||
|
Phase int `json:"phase"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Expected string `json:"expected"`
|
||||||
|
Actual string `json:"actual"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Required bool `json:"required"` // false → informational only, doesn't fail audit
|
||||||
|
Notes []string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhaseCheckReport is the aggregate result of one audit-full run.
|
||||||
|
// Metrics is the AuditBaseline-shape metric snapshot that the
|
||||||
|
// caller can pass to AppendBaseline to grow the longitudinal log.
|
||||||
|
type PhaseCheckReport struct {
|
||||||
|
Checks []PhaseCheck `json:"checks"`
|
||||||
|
Metrics map[string]int64 `json:"metrics"`
|
||||||
|
Failed int `json:"failed"` // count of REQUIRED checks that failed
|
||||||
|
Skipped int `json:"deferred_phases"` // phases not yet ported
|
||||||
|
GitHEAD string `json:"git_head,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditFullOptions controls a single audit-full run. Root is the
|
||||||
|
// data dir (defaults to LH_DISTILL_ROOT or /home/profit/lakehouse
|
||||||
|
// to keep operators running both runtimes hitting the same paths).
|
||||||
|
type AuditFullOptions struct {
|
||||||
|
Root string
|
||||||
|
GitHEAD string // optional — caller resolves and passes through
|
||||||
|
// GoTestModule is the package-pattern Phase 1 invokes via
|
||||||
|
// `go test`. Defaults to "./internal/distillation/..." when
|
||||||
|
// empty. Tests pass an empty path to disable the live
|
||||||
|
// `go test` invocation (which would recurse).
|
||||||
|
GoTestModule string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunAuditFull orchestrates the ported phases (0, 3, 4) and
|
||||||
|
// returns the aggregated report. Each phase is independent; a
|
||||||
|
// phase that errors is recorded as a failed check rather than
|
||||||
|
// aborting the run, matching Rust's "always emit a report" stance.
|
||||||
|
func RunAuditFull(opts AuditFullOptions) PhaseCheckReport {
|
||||||
|
if opts.Root == "" {
|
||||||
|
if env := os.Getenv("LH_DISTILL_ROOT"); env != "" {
|
||||||
|
opts.Root = env
|
||||||
|
} else {
|
||||||
|
opts.Root = "/home/profit/lakehouse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report := PhaseCheckReport{
|
||||||
|
Metrics: make(map[string]int64),
|
||||||
|
GitHEAD: opts.GitHEAD,
|
||||||
|
Skipped: 1, // only phase 6 (TS-only acceptance harness) deferred
|
||||||
|
}
|
||||||
|
auditPhase0(opts.Root, &report)
|
||||||
|
auditPhase1(opts.Root, &report, opts.GoTestModule)
|
||||||
|
auditPhase2(opts.Root, &report)
|
||||||
|
auditPhase3(opts.Root, &report)
|
||||||
|
auditPhase4(opts.Root, &report)
|
||||||
|
auditPhase5(opts.Root, &report)
|
||||||
|
// phase 6 intentionally skipped — see header comment
|
||||||
|
auditPhase7(opts.Root, &report)
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Required && !c.Passed {
|
||||||
|
report.Failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 0: file presence ─────────────────────────────────────────
|
||||||
|
|
||||||
|
func auditPhase0(root string, report *PhaseCheckReport) {
|
||||||
|
// The recon doc is Rust-specific (docs/recon/local-distillation-
|
||||||
|
// recon.md); a Go-side equivalent would live in the
|
||||||
|
// golangLAKEHOUSE repo. For audit-full's purposes, we treat its
|
||||||
|
// presence as informational rather than required when running
|
||||||
|
// against a non-Rust root.
|
||||||
|
reconPath := filepath.Join(root, "docs", "recon", "local-distillation-recon.md")
|
||||||
|
exists := fileExists(reconPath)
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 0, Name: "recon doc exists",
|
||||||
|
Expected: "docs/recon/local-distillation-recon.md present",
|
||||||
|
Actual: fmt.Sprintf("%v", exists),
|
||||||
|
Passed: exists, Required: false, // informational on Go-side runs
|
||||||
|
})
|
||||||
|
|
||||||
|
tier1 := []string{
|
||||||
|
"data/_kb/distilled_facts.jsonl",
|
||||||
|
"data/_kb/scrum_reviews.jsonl",
|
||||||
|
"data/_kb/audit_facts.jsonl",
|
||||||
|
"data/_kb/mode_experiments.jsonl",
|
||||||
|
}
|
||||||
|
missing := []string{}
|
||||||
|
for _, p := range tier1 {
|
||||||
|
if !fileExists(filepath.Join(root, p)) {
|
||||||
|
missing = append(missing, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notes := []string{}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
notes = append(notes, "fresh-clone or post-rotation environment — Phase 2 will tally as rows_present=false; not a hard fail")
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 0, Name: "tier-1 source streams present",
|
||||||
|
Expected: "all 4 tier-1 jsonls on disk",
|
||||||
|
Actual: func() string {
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return "all present"
|
||||||
|
}
|
||||||
|
return "missing: " + strings.Join(missing, ", ")
|
||||||
|
}(),
|
||||||
|
Passed: len(missing) == 0, Required: false,
|
||||||
|
Notes: notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 1: schema validators ─────────────────────────────────────
|
||||||
|
|
||||||
|
// auditPhase1 invokes `go test` on the distillation package — the Go
|
||||||
|
// equivalent of Rust's `bun test auditor/schemas/distillation/`. The
|
||||||
|
// audit-FULL semantic: "do the schema validators still pass on
|
||||||
|
// fixtures?" When module == "" (test mode) the phase records a
|
||||||
|
// skipped-with-rationale check rather than recursing into itself.
|
||||||
|
func auditPhase1(root string, report *PhaseCheckReport, module string) {
|
||||||
|
if module == "" {
|
||||||
|
// Test-disabled mode: record but don't invoke (would recurse
|
||||||
|
// when called from a `go test` already in progress).
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 1, Name: "schema validators (skipped — test invocation disabled)",
|
||||||
|
Expected: "go test ./internal/distillation/...",
|
||||||
|
Actual: "skipped",
|
||||||
|
Passed: true, Required: false,
|
||||||
|
Notes: []string{"caller passed empty GoTestModule — typically because we're already inside a test run"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd := exec.Command("go", "test", "-count=1", module)
|
||||||
|
cmd.Dir = root // run from go module root if caller supplied it; otherwise cwd
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
passed := err == nil
|
||||||
|
actual := "PASS"
|
||||||
|
if !passed {
|
||||||
|
actual = "FAIL — " + abbrevOutput(string(out), 200)
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 1, Name: "schema validators pass on fixtures",
|
||||||
|
Expected: "go test ./internal/distillation/... → exit 0",
|
||||||
|
Actual: actual,
|
||||||
|
Passed: passed, Required: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// abbrevOutput truncates noisy command-output to a stable preview.
|
||||||
|
// Long stack traces would blow out the report Markdown without this.
|
||||||
|
func abbrevOutput(s string, max int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max] + "...(truncated)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: evidence materialization (observer) ───────────────────
|
||||||
|
|
||||||
|
// auditPhase2 reads data/evidence/ and tallies rows + skipped
|
||||||
|
// markers. Mirrors the Rust phase 2's "materializer dry-run
|
||||||
|
// completes / tier-1 sources each materialize ≥1 row" checks but
|
||||||
|
// in OBSERVER mode — doesn't re-run the materializer (which is
|
||||||
|
// Rust-side); instead reads what the Rust side already produced.
|
||||||
|
//
|
||||||
|
// Records p2_evidence_rows + p2_evidence_skips metrics that match
|
||||||
|
// the Rust shape, so a Go-side audit-full producing baselines is
|
||||||
|
// drop-in-comparable to a Rust-side run.
|
||||||
|
func auditPhase2(root string, report *PhaseCheckReport) {
|
||||||
|
evidenceDir := filepath.Join(root, "data", "evidence")
|
||||||
|
if !fileExists(evidenceDir) {
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 2, Name: "evidence materialization output present",
|
||||||
|
Expected: "data/evidence/ populated",
|
||||||
|
Actual: "missing",
|
||||||
|
Passed: false, Required: true,
|
||||||
|
Notes: []string{"run materializer (Rust: ./scripts/distill collect; Go-side materializer not yet ported) before audit-full"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows := int64(0)
|
||||||
|
skips := int64(0)
|
||||||
|
bySource := map[string]int64{}
|
||||||
|
tier1Hits := map[string]bool{
|
||||||
|
"distilled_facts": false,
|
||||||
|
"scrum_reviews": false,
|
||||||
|
"audit_facts": false,
|
||||||
|
"mode_experiments": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
walkErr := filepath.Walk(evidenceDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() || !strings.HasSuffix(path, ".jsonl") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Tally per-source via the ev.provenance.source_file field on
|
||||||
|
// each evidence row. Match Rust's "by_source" map shape.
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows++
|
||||||
|
var rec struct {
|
||||||
|
Provenance struct {
|
||||||
|
SourceFile string `json:"source_file"`
|
||||||
|
} `json:"provenance"`
|
||||||
|
SuccessMarkers []string `json:"success_markers,omitempty"`
|
||||||
|
FailureMarkers []string `json:"failure_markers,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||||
|
skips++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stem := stemFromSourceFile(rec.Provenance.SourceFile)
|
||||||
|
bySource[stem]++
|
||||||
|
if _, ok := tier1Hits[stem]; ok {
|
||||||
|
tier1Hits[stem] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if walkErr != nil {
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 2, Name: "evidence walk",
|
||||||
|
Expected: "no error", Actual: walkErr.Error(),
|
||||||
|
Passed: false, Required: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
report.Metrics["p2_evidence_rows"] = rows
|
||||||
|
report.Metrics["p2_evidence_skips"] = skips
|
||||||
|
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 2, Name: "evidence materialization output non-empty",
|
||||||
|
Expected: ">=1 row across all sources",
|
||||||
|
Actual: fmt.Sprintf("%d rows · %d skipped", rows, skips),
|
||||||
|
Passed: rows >= 1, Required: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
tier1Found := []string{}
|
||||||
|
for src, hit := range tier1Hits {
|
||||||
|
if hit {
|
||||||
|
tier1Found = append(tier1Found, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(tier1Found)
|
||||||
|
notes := []string{}
|
||||||
|
if len(tier1Found) < 4 {
|
||||||
|
notes = append(notes, "fresh-environment OK; expect lower count when source streams are absent")
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 2, Name: "tier-1 sources each materialize ≥1 row",
|
||||||
|
Expected: "4/4: distilled_facts, scrum_reviews, audit_facts, mode_experiments",
|
||||||
|
Actual: fmt.Sprintf("%d/4 hit (%s)", len(tier1Found), strings.Join(tier1Found, ", ")),
|
||||||
|
Passed: len(tier1Found) >= 1, Required: false,
|
||||||
|
Notes: notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: scored-runs distribution ──────────────────────────────
|
||||||
|
|
||||||
|
func auditPhase3(root string, report *PhaseCheckReport) {
|
||||||
|
scoredDir := filepath.Join(root, "data", "scored-runs")
|
||||||
|
if !fileExists(scoredDir) {
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 3, Name: "scored-runs on disk",
|
||||||
|
Expected: "data/scored-runs/ populated",
|
||||||
|
Actual: "missing",
|
||||||
|
Passed: false, Required: true,
|
||||||
|
Notes: []string{"run scoring before audit-full (Go: scripts/distillation/score; Rust: ./scripts/distill score)"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := map[string]int64{
|
||||||
|
"accepted": 0,
|
||||||
|
"partially_accepted": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"needs_human_review": 0,
|
||||||
|
}
|
||||||
|
files, err := ListScoredRunFiles(root)
|
||||||
|
if err != nil {
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 3, Name: "scored-runs walk",
|
||||||
|
Expected: "no error", Actual: err.Error(),
|
||||||
|
Passed: false, Required: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
runs, _, err := LoadScoredRunsFromFile(f)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range runs {
|
||||||
|
if _, ok := counts[string(r.Category)]; ok {
|
||||||
|
counts[string(r.Category)]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := counts["accepted"] + counts["partially_accepted"] + counts["rejected"] + counts["needs_human_review"]
|
||||||
|
|
||||||
|
report.Metrics["p3_accepted"] = counts["accepted"]
|
||||||
|
report.Metrics["p3_partial"] = counts["partially_accepted"]
|
||||||
|
report.Metrics["p3_rejected"] = counts["rejected"]
|
||||||
|
report.Metrics["p3_human"] = counts["needs_human_review"]
|
||||||
|
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 3, Name: "on-disk scored-runs distribution non-empty",
|
||||||
|
Expected: ">=1 accepted",
|
||||||
|
Actual: fmt.Sprintf("acc=%d part=%d rej=%d hum=%d", counts["accepted"], counts["partially_accepted"], counts["rejected"], counts["needs_human_review"]),
|
||||||
|
Passed: counts["accepted"] >= 1, Required: true,
|
||||||
|
})
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 3, Name: "scored-runs distribution sums positive",
|
||||||
|
Expected: ">0 total", Actual: fmt.Sprintf("%d total", total),
|
||||||
|
Passed: total > 0, Required: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 4: contamination firewall + provenance ───────────────────
|
||||||
|
|
||||||
|
// sigHashRe pre-compiled match for the canonical sig_hash shape:
|
||||||
|
// 64 lowercase hex characters (sha256 hex). Used per-row in the
|
||||||
|
// provenance check.
|
||||||
|
var sigHashRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||||
|
|
||||||
|
func auditPhase4(root string, report *PhaseCheckReport) {
|
||||||
|
sftPath := filepath.Join(root, "exports", "sft", "instruction_response.jsonl")
|
||||||
|
ragPath := filepath.Join(root, "exports", "rag", "playbooks.jsonl")
|
||||||
|
prefPath := filepath.Join(root, "exports", "preference", "chosen_rejected.jsonl")
|
||||||
|
|
||||||
|
sftRows := readJSONLLines(sftPath)
|
||||||
|
ragRows := readJSONLLines(ragPath)
|
||||||
|
prefRows := readJSONLLines(prefPath)
|
||||||
|
|
||||||
|
report.Metrics["p4_sft_rows"] = int64(len(sftRows))
|
||||||
|
report.Metrics["p4_rag_rows"] = int64(len(ragRows))
|
||||||
|
report.Metrics["p4_pref_pairs"] = int64(len(prefRows))
|
||||||
|
|
||||||
|
// SFT contamination firewall: 0 forbidden quality_scores. The
|
||||||
|
// only legal SFT quality scores are accepted + partially_accepted.
|
||||||
|
sftForbidden := 0
|
||||||
|
for _, line := range sftRows {
|
||||||
|
var r struct {
|
||||||
|
QualityScore string `json:"quality_score"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &r); err != nil {
|
||||||
|
continue // tolerate malformed (matches Rust)
|
||||||
|
}
|
||||||
|
if r.QualityScore != "accepted" && r.QualityScore != "partially_accepted" {
|
||||||
|
sftForbidden++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 4, Name: "SFT contamination firewall: 0 forbidden quality_scores",
|
||||||
|
Expected: "0", Actual: fmt.Sprintf("%d", sftForbidden),
|
||||||
|
Passed: sftForbidden == 0, Required: true,
|
||||||
|
Notes: []string{"this is the spec non-negotiable — rejected/needs_human_review must NEVER appear in SFT"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// RAG firewall: 0 rejected leaks
|
||||||
|
ragRejected := 0
|
||||||
|
for _, line := range ragRows {
|
||||||
|
var r struct {
|
||||||
|
SuccessScore string `json:"success_score"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &r); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.SuccessScore == "rejected" {
|
||||||
|
ragRejected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 4, Name: "RAG firewall: 0 rejected leaks",
|
||||||
|
Expected: "0", Actual: fmt.Sprintf("%d", ragRejected),
|
||||||
|
Passed: ragRejected == 0, Required: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Preference: 0 self-pairs + 0 identical-text pairs.
|
||||||
|
prefSelfPairs, prefIdenticalText := 0, 0
|
||||||
|
for _, line := range prefRows {
|
||||||
|
var r struct {
|
||||||
|
ChosenRunID string `json:"chosen_run_id"`
|
||||||
|
RejectedRunID string `json:"rejected_run_id"`
|
||||||
|
Chosen string `json:"chosen"`
|
||||||
|
Rejected string `json:"rejected"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &r); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.ChosenRunID == r.RejectedRunID {
|
||||||
|
prefSelfPairs++
|
||||||
|
}
|
||||||
|
if r.Chosen == r.Rejected {
|
||||||
|
prefIdenticalText++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 4, Name: "Preference: 0 self-pairs (chosen_run_id != rejected_run_id)",
|
||||||
|
Expected: "0", Actual: fmt.Sprintf("%d", prefSelfPairs),
|
||||||
|
Passed: prefSelfPairs == 0, Required: true,
|
||||||
|
})
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 4, Name: "Preference: 0 identical-text pairs",
|
||||||
|
Expected: "0", Actual: fmt.Sprintf("%d", prefIdenticalText),
|
||||||
|
Passed: prefIdenticalText == 0, Required: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Provenance check: every export row must carry a 64-char hex
|
||||||
|
// sig_hash. Walks sft + rag + pref together since the contract
|
||||||
|
// is uniform across all three.
|
||||||
|
noProv := 0
|
||||||
|
checkProv := func(line string) {
|
||||||
|
var r struct {
|
||||||
|
Provenance struct {
|
||||||
|
SigHash string `json:"sig_hash"`
|
||||||
|
} `json:"provenance"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Provenance.SigHash == "" || !sigHashRe.MatchString(r.Provenance.SigHash) {
|
||||||
|
noProv++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range sftRows {
|
||||||
|
checkProv(line)
|
||||||
|
}
|
||||||
|
for _, line := range ragRows {
|
||||||
|
checkProv(line)
|
||||||
|
}
|
||||||
|
for _, line := range prefRows {
|
||||||
|
checkProv(line)
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 4, Name: "every export row carries valid sha256 provenance.sig_hash",
|
||||||
|
Expected: "0 missing", Actual: fmt.Sprintf("%d missing", noProv),
|
||||||
|
Passed: noProv == 0, Required: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Quarantine totals (informational — feeds the p4_total_quarantined
|
||||||
|
// metric used by the longitudinal drift signal).
|
||||||
|
totalQuar := int64(0)
|
||||||
|
for _, qp := range []string{
|
||||||
|
"exports/quarantine/sft.jsonl",
|
||||||
|
"exports/quarantine/rag.jsonl",
|
||||||
|
"exports/quarantine/preference.jsonl",
|
||||||
|
} {
|
||||||
|
totalQuar += int64(len(readJSONLLines(filepath.Join(root, qp))))
|
||||||
|
}
|
||||||
|
report.Metrics["p4_total_quarantined"] = totalQuar
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 5: receipts validation (observer) ────────────────────────
|
||||||
|
|
||||||
|
// runSummaryShape mirrors the Rust RunSummary just enough to
|
||||||
|
// validate the file's shape — schema_version, run_hash sha256,
|
||||||
|
// git_commit hex, and the 5 stage names. Full schema validation
|
||||||
|
// is intentionally NOT ported (it would require porting the
|
||||||
|
// schemas/distillation/ TS validators); we check the load-bearing
|
||||||
|
// invariants and call it good.
|
||||||
|
type runSummaryShape struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
GitCommit string `json:"git_commit"`
|
||||||
|
RunHash string `json:"run_hash"`
|
||||||
|
Stages []struct {
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
} `json:"stages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditPhase5(root string, report *PhaseCheckReport) {
|
||||||
|
reportsDir := filepath.Join(root, "reports", "distillation")
|
||||||
|
if !fileExists(reportsDir) {
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 5, Name: "receipts directory exists",
|
||||||
|
Expected: "reports/distillation/", Actual: "MISSING",
|
||||||
|
Passed: false, Required: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Find the most recent run_id directory with a summary.json.
|
||||||
|
// Mirrors the Rust mtime-sort behavior — ordering matters when
|
||||||
|
// both Rust + Go runs land in the same directory.
|
||||||
|
type cand struct {
|
||||||
|
id string
|
||||||
|
mtime int64
|
||||||
|
}
|
||||||
|
var cands []cand
|
||||||
|
entries, err := os.ReadDir(reportsDir)
|
||||||
|
if err != nil {
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 5, Name: "scan reports/distillation",
|
||||||
|
Expected: "no error", Actual: err.Error(),
|
||||||
|
Passed: false, Required: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sumPath := filepath.Join(reportsDir, e.Name(), "summary.json")
|
||||||
|
st, err := os.Stat(sumPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cands = append(cands, cand{id: e.Name(), mtime: st.ModTime().UnixMilli()})
|
||||||
|
}
|
||||||
|
if len(cands) == 0 {
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 5, Name: "≥1 run with summary.json",
|
||||||
|
Expected: "≥1", Actual: "0",
|
||||||
|
Passed: false, Required: false,
|
||||||
|
Notes: []string{"no Phase 5 run-all has executed yet — Rust: ./scripts/distill run-all"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(cands, func(i, j int) bool { return cands[i].mtime > cands[j].mtime })
|
||||||
|
latest := cands[0]
|
||||||
|
runDir := filepath.Join(reportsDir, latest.id)
|
||||||
|
|
||||||
|
// All 5 stage receipts present.
|
||||||
|
expected := []string{"collect", "score", "export-rag", "export-sft", "export-preference"}
|
||||||
|
missing := []string{}
|
||||||
|
for _, s := range expected {
|
||||||
|
if !fileExists(filepath.Join(runDir, s+".json")) {
|
||||||
|
missing = append(missing, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 5, Name: fmt.Sprintf("latest run (%s) has all 5 stage receipts", latest.id),
|
||||||
|
Expected: strings.Join(expected, ","),
|
||||||
|
Actual: func() string {
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return "all present"
|
||||||
|
}
|
||||||
|
return "missing: " + strings.Join(missing, ",")
|
||||||
|
}(),
|
||||||
|
Passed: len(missing) == 0, Required: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each receipt parses as JSON. Full schema validation (StageReceipt
|
||||||
|
// schema) is Rust-side only; we check basic decodability here.
|
||||||
|
invalid := 0
|
||||||
|
for _, s := range expected {
|
||||||
|
path := filepath.Join(runDir, s+".json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var anyShape any
|
||||||
|
if err := json.Unmarshal(data, &anyShape); err != nil {
|
||||||
|
invalid++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 5, Name: "every stage receipt parses as JSON",
|
||||||
|
Expected: "0 invalid", Actual: fmt.Sprintf("%d invalid", invalid),
|
||||||
|
Passed: invalid == 0, Required: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// RunSummary shape: schema_version=1, run_hash sha256, git_commit
|
||||||
|
// 40-char hex.
|
||||||
|
summaryPath := filepath.Join(runDir, "summary.json")
|
||||||
|
data, err := os.ReadFile(summaryPath)
|
||||||
|
if err != nil {
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 5, Name: "summary.json readable",
|
||||||
|
Expected: "ok", Actual: err.Error(),
|
||||||
|
Passed: false, Required: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sum runSummaryShape
|
||||||
|
if err := json.Unmarshal(data, &sum); err != nil {
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 5, Name: "summary.json decodable",
|
||||||
|
Expected: "ok", Actual: err.Error(),
|
||||||
|
Passed: false, Required: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 5, Name: "summary.schema_version == 1",
|
||||||
|
Expected: "1", Actual: fmt.Sprintf("%d", sum.SchemaVersion),
|
||||||
|
Passed: sum.SchemaVersion == 1, Required: true,
|
||||||
|
})
|
||||||
|
gitHEADRe := regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 5, Name: "summary.git_commit is 40-char hex",
|
||||||
|
Expected: "/^[0-9a-f]{40}$/", Actual: shortHash(sum.GitCommit),
|
||||||
|
Passed: gitHEADRe.MatchString(sum.GitCommit), Required: false,
|
||||||
|
})
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 5, Name: "run_hash is sha256",
|
||||||
|
Expected: "/^[0-9a-f]{64}$/", Actual: shortHash(sum.RunHash),
|
||||||
|
Passed: sigHashRe.MatchString(sum.RunHash), Required: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortHash(h string) string {
|
||||||
|
if len(h) <= 16 {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return h[:16] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 7: replay log shape (observer) ───────────────────────────
|
||||||
|
|
||||||
|
// auditPhase7 checks data/_kb/replay_runs.jsonl exists and contains
|
||||||
|
// well-shaped records. Mirrors Rust phase 7's "persisted log shape"
|
||||||
|
// check but skips the live-replay invocation (which would require
|
||||||
|
// porting Rust replay.ts, a substantial effort). The full Rust
|
||||||
|
// phase 7 also runs 3 dry-run replays — operators wanting that
|
||||||
|
// signal continue to invoke the Rust audit-full.
|
||||||
|
func auditPhase7(root string, report *PhaseCheckReport) {
|
||||||
|
logPath := filepath.Join(root, "data", "_kb", "replay_runs.jsonl")
|
||||||
|
lines := readJSONLLines(logPath)
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 7, Name: "replay_runs.jsonl exists",
|
||||||
|
Expected: "exists with ≥1 row",
|
||||||
|
Actual: func() string {
|
||||||
|
if !fileExists(logPath) {
|
||||||
|
return "missing"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d rows total", len(lines))
|
||||||
|
}(),
|
||||||
|
Passed: fileExists(logPath), Required: false,
|
||||||
|
})
|
||||||
|
if !fileExists(logPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Validate shape on a sample of rows — full validation across
|
||||||
|
// thousands of lines isn't worth the cost, and a structural
|
||||||
|
// problem will show up in any sample.
|
||||||
|
sample := lines
|
||||||
|
if len(sample) > 50 {
|
||||||
|
sample = sample[len(sample)-50:]
|
||||||
|
}
|
||||||
|
malformed := 0
|
||||||
|
for _, line := range sample {
|
||||||
|
var anyShape any
|
||||||
|
if err := json.Unmarshal([]byte(line), &anyShape); err != nil {
|
||||||
|
malformed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.Checks = append(report.Checks, PhaseCheck{
|
||||||
|
Phase: 7, Name: "replay_runs.jsonl tail rows parse as JSON",
|
||||||
|
Expected: "0 malformed in last 50", Actual: fmt.Sprintf("%d malformed", malformed),
|
||||||
|
Passed: malformed == 0, Required: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func fileExists(p string) bool {
|
||||||
|
_, err := os.Stat(p)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readJSONLLines reads a JSONL file and returns non-empty lines.
|
||||||
|
// Returns nil on missing file (matches Rust's existsSync ? read : []).
|
||||||
|
func readJSONLLines(path string) []string {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0)
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.TrimSpace(line) != "" {
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatAuditFullReport renders a Markdown report mirroring the
|
||||||
|
// Rust phase8-full-audit-report.md shape so operators reading
|
||||||
|
// across runtimes don't have to re-learn the layout.
|
||||||
|
func FormatAuditFullReport(report PhaseCheckReport) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintln(&b, "# Audit-FULL report (Go)")
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
if report.GitHEAD != "" {
|
||||||
|
fmt.Fprintf(&b, "**git HEAD:** `%s`\n\n", report.GitHEAD)
|
||||||
|
}
|
||||||
|
failed := report.Failed
|
||||||
|
total := 0
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Required {
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verdict := "PASS"
|
||||||
|
if failed > 0 {
|
||||||
|
verdict = "FAIL"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "**Verdict:** %s — %d/%d required checks passed; %d phase(s) deferred.\n\n",
|
||||||
|
verdict, total-failed, total, report.Skipped)
|
||||||
|
|
||||||
|
fmt.Fprintln(&b, "## Checks")
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
fmt.Fprintln(&b, "| phase | name | expected | actual | required | passed |")
|
||||||
|
fmt.Fprintln(&b, "|---|---|---|---|---|---|")
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
req := "no"
|
||||||
|
if c.Required {
|
||||||
|
req = "**yes**"
|
||||||
|
}
|
||||||
|
passed := "✗"
|
||||||
|
if c.Passed {
|
||||||
|
passed = "✓"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "| %d | %s | %s | %s | %s | %s |\n",
|
||||||
|
c.Phase, c.Name, c.Expected, c.Actual, req, passed)
|
||||||
|
for _, n := range c.Notes {
|
||||||
|
fmt.Fprintf(&b, "| | _note_ | %s | | | |\n", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(report.Metrics) > 0 {
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
fmt.Fprintln(&b, "## Metrics")
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
fmt.Fprintln(&b, "| metric | value |")
|
||||||
|
fmt.Fprintln(&b, "|---|---:|")
|
||||||
|
// Stable order for diffs.
|
||||||
|
names := make([]string, 0, len(report.Metrics))
|
||||||
|
for k := range report.Metrics {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, k := range names {
|
||||||
|
fmt.Fprintf(&b, "| %s | %d |\n", k, report.Metrics[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package distillation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunAuditFull_EmptyRoot: missing data directories yield
|
||||||
|
// failures on required checks but doesn't error out the run.
|
||||||
|
// Operator running on a fresh box sees the report with the
|
||||||
|
// expected "missing" actuals.
|
||||||
|
func TestRunAuditFull_EmptyRoot(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
report := RunAuditFull(AuditFullOptions{Root: tmp})
|
||||||
|
if len(report.Checks) == 0 {
|
||||||
|
t.Fatalf("expected check rows even on empty root, got %d", len(report.Checks))
|
||||||
|
}
|
||||||
|
// Phase 3's "scored-runs on disk" must fail (required); the
|
||||||
|
// failure count rises by at least 1.
|
||||||
|
if report.Failed < 1 {
|
||||||
|
t.Errorf("expected ≥1 required failure on empty root, got %d", report.Failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhase2_EvidenceTallyFromOnDisk seeds data/evidence/ and
|
||||||
|
// asserts phase 2 reads + tallies the rows correctly. The
|
||||||
|
// observer-mode port (no live materializer invocation) means the
|
||||||
|
// check works against any-runtime-emitted evidence files.
|
||||||
|
func TestPhase2_EvidenceTallyFromOnDisk(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
dir := filepath.Join(tmp, "data", "evidence", "2026", "05", "01")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
// 3 records: 2 from scrum_reviews (a tier-1 source), 1 from
|
||||||
|
// "other_source" (not in tier-1 list). Phase 2 should tally
|
||||||
|
// 3 rows total + flag 1/4 tier-1 sources hit.
|
||||||
|
jsonl := `{"run_id":"r1","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"a","recorded_at":"2026-05-01T00:00:00Z"}}
|
||||||
|
{"run_id":"r2","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"b","recorded_at":"2026-05-01T00:00:00Z"}}
|
||||||
|
{"run_id":"r3","provenance":{"source_file":"data/_kb/other_source.jsonl","sig_hash":"c","recorded_at":"2026-05-01T00:00:00Z"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "evidence.jsonl"), []byte(jsonl), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
report := RunAuditFull(AuditFullOptions{Root: tmp}) // GoTestModule empty disables phase 1
|
||||||
|
if report.Metrics["p2_evidence_rows"] != 3 {
|
||||||
|
t.Errorf("p2_evidence_rows: got %d, want 3", report.Metrics["p2_evidence_rows"])
|
||||||
|
}
|
||||||
|
if report.Metrics["p2_evidence_skips"] != 0 {
|
||||||
|
t.Errorf("p2_evidence_skips: got %d, want 0", report.Metrics["p2_evidence_skips"])
|
||||||
|
}
|
||||||
|
// Find the tier-1 hit count check.
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Phase == 2 && c.Name == "tier-1 sources each materialize ≥1 row" {
|
||||||
|
if !c.Passed {
|
||||||
|
t.Errorf("expected tier-1 check to pass with 1/4 sources hit (≥1 = ok), got %+v", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.Actual, "1/4") || !strings.Contains(c.Actual, "scrum_reviews") {
|
||||||
|
t.Errorf("tier-1 actual missing expected counts: %s", c.Actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhase5_FullSummaryFlow seeds reports/distillation/{run_id}/
|
||||||
|
// with summary.json + 5 stage receipts and asserts phase 5 passes
|
||||||
|
// all required checks.
|
||||||
|
func TestPhase5_FullSummaryFlow(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
runID := "test-run-id"
|
||||||
|
runDir := filepath.Join(tmp, "reports", "distillation", runID)
|
||||||
|
if err := os.MkdirAll(runDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
// 5 stage receipts (parse-as-JSON only — full schema validation
|
||||||
|
// is Rust-side).
|
||||||
|
for _, s := range []string{"collect", "score", "export-rag", "export-sft", "export-preference"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(runDir, s+".json"), []byte(`{}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// summary.json with valid schema_version, 40-char git_commit, 64-char run_hash.
|
||||||
|
summary := `{
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "test-run-id",
|
||||||
|
"git_commit": "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
"run_hash": "a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff0",
|
||||||
|
"stages": [{"stage":"collect"},{"stage":"score"},{"stage":"export-rag"},{"stage":"export-sft"},{"stage":"export-preference"}]
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(filepath.Join(runDir, "summary.json"), []byte(summary), 0o644); err != nil {
|
||||||
|
t.Fatalf("write summary: %v", err)
|
||||||
|
}
|
||||||
|
report := RunAuditFull(AuditFullOptions{Root: tmp})
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Phase == 5 && c.Required && !c.Passed {
|
||||||
|
t.Errorf("phase 5 required check failed: %s — actual=%q", c.Name, c.Actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhase5_ShortRunHashCaught: a run_hash that isn't 64-char hex
|
||||||
|
// must fail the required check.
|
||||||
|
func TestPhase5_ShortRunHashCaught(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
runDir := filepath.Join(tmp, "reports", "distillation", "id")
|
||||||
|
if err := os.MkdirAll(runDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range []string{"collect", "score", "export-rag", "export-sft", "export-preference"} {
|
||||||
|
_ = os.WriteFile(filepath.Join(runDir, s+".json"), []byte(`{}`), 0o644)
|
||||||
|
}
|
||||||
|
bad := `{"schema_version":1,"run_id":"id","git_commit":"0123456789abcdef0123456789abcdef01234567","run_hash":"too_short","stages":[]}`
|
||||||
|
_ = os.WriteFile(filepath.Join(runDir, "summary.json"), []byte(bad), 0o644)
|
||||||
|
report := RunAuditFull(AuditFullOptions{Root: tmp})
|
||||||
|
hashFailed := false
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Phase == 5 && c.Name == "run_hash is sha256" && !c.Passed {
|
||||||
|
hashFailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hashFailed {
|
||||||
|
t.Errorf("expected run_hash sha256 check to fail on too_short")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhase7_ReplayLogReadsFromDisk seeds a replay_runs.jsonl and
|
||||||
|
// asserts phase 7 reports the correct row count.
|
||||||
|
func TestPhase7_ReplayLogReadsFromDisk(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
dir := filepath.Join(tmp, "data", "_kb")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
jsonl := `{"task":"a","passed":true}
|
||||||
|
{"task":"b","passed":true}
|
||||||
|
{"task":"c","passed":false}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "replay_runs.jsonl"), []byte(jsonl), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
report := RunAuditFull(AuditFullOptions{Root: tmp})
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Phase == 7 && c.Name == "replay_runs.jsonl exists" {
|
||||||
|
if !c.Passed {
|
||||||
|
t.Errorf("expected pass, got %+v", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.Actual, "3 rows") {
|
||||||
|
t.Errorf("expected '3 rows' in actual, got %s", c.Actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhase7_MalformedTailRowsCaught seeds a replay log with a
|
||||||
|
// trailing malformed row and asserts the structural check fires.
|
||||||
|
func TestPhase7_MalformedTailRowsCaught(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
dir := filepath.Join(tmp, "data", "_kb")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
jsonl := `{"task":"a"}
|
||||||
|
{"task":"b"}
|
||||||
|
not valid json garbage
|
||||||
|
`
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, "replay_runs.jsonl"), []byte(jsonl), 0o644)
|
||||||
|
report := RunAuditFull(AuditFullOptions{Root: tmp})
|
||||||
|
parseFailed := false
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Phase == 7 && c.Name == "replay_runs.jsonl tail rows parse as JSON" && !c.Passed {
|
||||||
|
parseFailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !parseFailed {
|
||||||
|
t.Errorf("expected tail-row parse check to fail on malformed line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunAuditFull_FullFixtureFlow seeds a complete data layout
|
||||||
|
// and verifies all phases produce the expected metrics + a clean
|
||||||
|
// PASS verdict. Locks the end-to-end orchestration.
|
||||||
|
func TestRunAuditFull_FullFixtureFlow(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
// scored-runs: one accepted record (passes phase 3 required check)
|
||||||
|
scoredDir := filepath.Join(tmp, "data", "scored-runs", "2026", "05", "01")
|
||||||
|
if err := os.MkdirAll(scoredDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir scored: %v", err)
|
||||||
|
}
|
||||||
|
scoredJSONL := `{"category":"accepted","evidence_run_id":"r1","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff0","recorded_at":"2026-05-01T00:00:00Z"}}
|
||||||
|
{"category":"partially_accepted","evidence_run_id":"r2","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff1","recorded_at":"2026-05-01T00:00:00Z"}}
|
||||||
|
{"category":"rejected","evidence_run_id":"r3","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff2","recorded_at":"2026-05-01T00:00:00Z"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(scoredDir, "run.jsonl"), []byte(scoredJSONL), 0o644); err != nil {
|
||||||
|
t.Fatalf("write scored: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SFT export: only legal quality scores, valid sig_hash on every row.
|
||||||
|
sftDir := filepath.Join(tmp, "exports", "sft")
|
||||||
|
if err := os.MkdirAll(sftDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir sft: %v", err)
|
||||||
|
}
|
||||||
|
sftJSONL := `{"quality_score":"accepted","provenance":{"sig_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff0"}}
|
||||||
|
{"quality_score":"partially_accepted","provenance":{"sig_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff1"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(sftDir, "instruction_response.jsonl"), []byte(sftJSONL), 0o644); err != nil {
|
||||||
|
t.Fatalf("write sft: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAG: no rejected leaks
|
||||||
|
ragDir := filepath.Join(tmp, "exports", "rag")
|
||||||
|
if err := os.MkdirAll(ragDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir rag: %v", err)
|
||||||
|
}
|
||||||
|
ragJSONL := `{"success_score":"accepted","provenance":{"sig_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff0"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(ragDir, "playbooks.jsonl"), []byte(ragJSONL), 0o644); err != nil {
|
||||||
|
t.Fatalf("write rag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preference: distinct chosen vs rejected, no self-pairs
|
||||||
|
prefDir := filepath.Join(tmp, "exports", "preference")
|
||||||
|
if err := os.MkdirAll(prefDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir pref: %v", err)
|
||||||
|
}
|
||||||
|
prefJSONL := `{"chosen_run_id":"a","rejected_run_id":"b","chosen":"good","rejected":"bad","provenance":{"sig_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff0"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(prefDir, "chosen_rejected.jsonl"), []byte(prefJSONL), 0o644); err != nil {
|
||||||
|
t.Fatalf("write pref: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: evidence directory with at least one row.
|
||||||
|
evidenceDir := filepath.Join(tmp, "data", "evidence", "2026", "05", "01")
|
||||||
|
if err := os.MkdirAll(evidenceDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir evidence: %v", err)
|
||||||
|
}
|
||||||
|
evidenceJSONL := `{"run_id":"r1","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"a","recorded_at":"2026-05-01T00:00:00Z"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(evidenceDir, "evidence.jsonl"), []byte(evidenceJSONL), 0o644); err != nil {
|
||||||
|
t.Fatalf("write evidence: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5: reports/distillation/{run_id}/ with summary + 5 receipts.
|
||||||
|
runDir := filepath.Join(tmp, "reports", "distillation", "test-run")
|
||||||
|
if err := os.MkdirAll(runDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir runDir: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range []string{"collect", "score", "export-rag", "export-sft", "export-preference"} {
|
||||||
|
_ = os.WriteFile(filepath.Join(runDir, s+".json"), []byte(`{}`), 0o644)
|
||||||
|
}
|
||||||
|
summaryJSON := `{"schema_version":1,"run_id":"test-run","git_commit":"0123456789abcdef0123456789abcdef01234567","run_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff0","stages":[]}`
|
||||||
|
_ = os.WriteFile(filepath.Join(runDir, "summary.json"), []byte(summaryJSON), 0o644)
|
||||||
|
|
||||||
|
report := RunAuditFull(AuditFullOptions{Root: tmp})
|
||||||
|
if report.Failed != 0 {
|
||||||
|
t.Errorf("clean fixture should have 0 required failures, got %d", report.Failed)
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Required && !c.Passed {
|
||||||
|
t.Logf(" failed: phase=%d name=%q actual=%q", c.Phase, c.Name, c.Actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Metrics populated correctly
|
||||||
|
if report.Metrics["p3_accepted"] != 1 {
|
||||||
|
t.Errorf("p3_accepted: got %d, want 1", report.Metrics["p3_accepted"])
|
||||||
|
}
|
||||||
|
if report.Metrics["p3_partial"] != 1 {
|
||||||
|
t.Errorf("p3_partial: got %d, want 1", report.Metrics["p3_partial"])
|
||||||
|
}
|
||||||
|
if report.Metrics["p3_rejected"] != 1 {
|
||||||
|
t.Errorf("p3_rejected: got %d, want 1", report.Metrics["p3_rejected"])
|
||||||
|
}
|
||||||
|
if report.Metrics["p4_sft_rows"] != 2 {
|
||||||
|
t.Errorf("p4_sft_rows: got %d, want 2", report.Metrics["p4_sft_rows"])
|
||||||
|
}
|
||||||
|
if report.Metrics["p4_rag_rows"] != 1 {
|
||||||
|
t.Errorf("p4_rag_rows: got %d, want 1", report.Metrics["p4_rag_rows"])
|
||||||
|
}
|
||||||
|
if report.Metrics["p4_pref_pairs"] != 1 {
|
||||||
|
t.Errorf("p4_pref_pairs: got %d, want 1", report.Metrics["p4_pref_pairs"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhase4_SftFirewallCatchesRejected: contamination must never
|
||||||
|
// leak into SFT export. Test seeds a row with a forbidden
|
||||||
|
// quality_score and asserts the firewall flags it.
|
||||||
|
func TestPhase4_SftFirewallCatchesRejected(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
sftDir := filepath.Join(tmp, "exports", "sft")
|
||||||
|
if err := os.MkdirAll(sftDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
bad := `{"quality_score":"rejected","provenance":{"sig_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff0"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(sftDir, "instruction_response.jsonl"), []byte(bad), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
report := RunAuditFull(AuditFullOptions{Root: tmp})
|
||||||
|
found := false
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Phase == 4 && strings.Contains(c.Name, "SFT contamination firewall") {
|
||||||
|
if c.Passed {
|
||||||
|
t.Errorf("firewall should fail on rejected SFT row, but check passed")
|
||||||
|
}
|
||||||
|
if c.Actual != "1" {
|
||||||
|
t.Errorf("firewall actual: got %q, want '1'", c.Actual)
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("firewall check not present in report")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhase4_PreferenceSelfPairCaught: same chosen + rejected run_id
|
||||||
|
// is structural noise and must be flagged.
|
||||||
|
func TestPhase4_PreferenceSelfPairCaught(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
prefDir := filepath.Join(tmp, "exports", "preference")
|
||||||
|
if err := os.MkdirAll(prefDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
bad := `{"chosen_run_id":"X","rejected_run_id":"X","chosen":"a","rejected":"b","provenance":{"sig_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff0"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(prefDir, "chosen_rejected.jsonl"), []byte(bad), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
report := RunAuditFull(AuditFullOptions{Root: tmp})
|
||||||
|
found := false
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Phase == 4 && strings.Contains(c.Name, "self-pairs") {
|
||||||
|
if c.Passed {
|
||||||
|
t.Errorf("self-pair check should fail, but passed")
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("self-pair check not present in report")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhase4_ProvenanceRequiresValidSha256: bad sig_hash must be
|
||||||
|
// flagged. Locks the regex shape — only 64-char lowercase hex.
|
||||||
|
func TestPhase4_ProvenanceRequiresValidSha256(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
sftDir := filepath.Join(tmp, "exports", "sft")
|
||||||
|
if err := os.MkdirAll(sftDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
// Three rows: one valid, one wrong-length, one wrong-charset (uppercase).
|
||||||
|
bad := `{"quality_score":"accepted","provenance":{"sig_hash":"a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff0"}}
|
||||||
|
{"quality_score":"accepted","provenance":{"sig_hash":"too_short"}}
|
||||||
|
{"quality_score":"accepted","provenance":{"sig_hash":"A1B2C3D4E5F60718293A4B5C6D7E8F900112233445566778899AABBCCDDEEFF0"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(sftDir, "instruction_response.jsonl"), []byte(bad), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
report := RunAuditFull(AuditFullOptions{Root: tmp})
|
||||||
|
for _, c := range report.Checks {
|
||||||
|
if c.Phase == 4 && strings.Contains(c.Name, "sig_hash") {
|
||||||
|
if c.Actual != "2 missing" {
|
||||||
|
t.Errorf("provenance check: got actual=%q, want '2 missing'", c.Actual)
|
||||||
|
}
|
||||||
|
if c.Passed {
|
||||||
|
t.Errorf("provenance check should fail with 2 bad sig_hashes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatAuditFullReport_RendersCheckTable: smoke-test the
|
||||||
|
// Markdown formatter — operators should see the right verdict +
|
||||||
|
// per-phase rows.
|
||||||
|
func TestFormatAuditFullReport_RendersCheckTable(t *testing.T) {
|
||||||
|
report := PhaseCheckReport{
|
||||||
|
GitHEAD: "deadbeef",
|
||||||
|
Checks: []PhaseCheck{
|
||||||
|
{Phase: 0, Name: "test check", Expected: "x", Actual: "x", Passed: true, Required: true},
|
||||||
|
{Phase: 4, Name: "fail check", Expected: "0", Actual: "5", Passed: false, Required: true},
|
||||||
|
},
|
||||||
|
Metrics: map[string]int64{"p3_accepted": 42, "p4_sft_rows": 17},
|
||||||
|
Failed: 1,
|
||||||
|
Skipped: 4,
|
||||||
|
}
|
||||||
|
out := FormatAuditFullReport(report)
|
||||||
|
for _, want := range []string{"FAIL", "deadbeef", "test check", "fail check", "p3_accepted", "42", "deferred"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("expected %q in formatted report:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package distillation
|
||||||
|
|
||||||
|
// scorer.go — pure deterministic Success Scorer (port of Rust
|
||||||
|
// scripts/distillation/scorer.ts at e7636f2).
|
||||||
|
//
|
||||||
|
// Takes one EvidenceRecord, returns category + reasons + sub_scores.
|
||||||
|
// NO I/O, NO LLM, NO clock reads, NO mutable state. Identical input
|
||||||
|
// → identical output forever. Same contract as the Rust source —
|
||||||
|
// future scoring-rule changes bump ScorerVersion atomically with
|
||||||
|
// the logic.
|
||||||
|
//
|
||||||
|
// Three-class strategy mirrors the Rust source taxonomy
|
||||||
|
// (docs/recon/local-distillation-recon.md + data/_kb/evidence_health.md):
|
||||||
|
//
|
||||||
|
// CLASS A — verdict-bearing
|
||||||
|
// scrum_reviews, observer_reviews, audits, contract_analyses
|
||||||
|
// Direct scoring from existing markers / observer_verdict
|
||||||
|
//
|
||||||
|
// CLASS B — telemetry-rich
|
||||||
|
// auto_apply, outcomes, mode_experiments
|
||||||
|
// Markers exist but partial; needs_human_review fills the gap
|
||||||
|
//
|
||||||
|
// CLASS C — pure-extraction (no native scoring signal)
|
||||||
|
// distilled_*, audit_facts, observer_escalations
|
||||||
|
// Default needs_human_review; v2 will JOIN to parent verdict
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sourceClass categorizes an EvidenceRecord's source_file for the
|
||||||
|
// scorer's three-class dispatch.
|
||||||
|
type sourceClass string
|
||||||
|
|
||||||
|
const (
|
||||||
|
classVerdict sourceClass = "verdict"
|
||||||
|
classTelemetry sourceClass = "telemetry"
|
||||||
|
classExtraction sourceClass = "extraction"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sourceClassFor maps a source_file (from provenance) to a class.
|
||||||
|
// Centralized so adding a new source is a one-line change. Mirrors
|
||||||
|
// the Rust switch on the stem (data/_kb/X.jsonl → X).
|
||||||
|
func sourceClassFor(sourceFile string) sourceClass {
|
||||||
|
stem := strings.TrimSuffix(strings.TrimPrefix(sourceFile, "data/_kb/"), ".jsonl")
|
||||||
|
switch stem {
|
||||||
|
case "scrum_reviews", "observer_reviews", "audits", "contract_analyses":
|
||||||
|
return classVerdict
|
||||||
|
case "auto_apply", "outcomes", "mode_experiments":
|
||||||
|
return classTelemetry
|
||||||
|
case "distilled_facts", "distilled_procedures", "distilled_config_hints",
|
||||||
|
"audit_facts", "observer_escalations":
|
||||||
|
return classExtraction
|
||||||
|
default:
|
||||||
|
// Unknown source → most conservative path (forces
|
||||||
|
// needs_human_review until a transform is added).
|
||||||
|
return classExtraction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stemOf extracts the stable corpus identifier from a source_file.
|
||||||
|
// E.g. "data/_kb/scrum_reviews.jsonl" → "scrum_reviews".
|
||||||
|
func stemOf(sourceFile string) string {
|
||||||
|
return strings.TrimSuffix(strings.TrimPrefix(sourceFile, "data/_kb/"), ".jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScoreOutput is the scorer's return shape — category + reasons +
|
||||||
|
// the captured sub-signals. Reasons is always non-empty (validator
|
||||||
|
// requires it).
|
||||||
|
type ScoreOutput struct {
|
||||||
|
Category ScoreCategory
|
||||||
|
Reasons []string
|
||||||
|
SubScores *SubScores
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScoreRecord dispatches an EvidenceRecord to the appropriate class
|
||||||
|
// scorer and returns the verdict + reasons + sub-scores. Pure
|
||||||
|
// function. Caller wraps the output in a ScoredRun via BuildScoredRun
|
||||||
|
// for the on-wire shape.
|
||||||
|
func ScoreRecord(rec EvidenceRecord) ScoreOutput {
|
||||||
|
cls := sourceClassFor(rec.Provenance.SourceFile)
|
||||||
|
stem := stemOf(rec.Provenance.SourceFile)
|
||||||
|
|
||||||
|
switch cls {
|
||||||
|
case classVerdict:
|
||||||
|
switch stem {
|
||||||
|
case "scrum_reviews":
|
||||||
|
return scoreScrumReview(rec)
|
||||||
|
case "observer_reviews":
|
||||||
|
return scoreObserverReview(rec)
|
||||||
|
case "audits":
|
||||||
|
return scoreAudit(rec)
|
||||||
|
case "contract_analyses":
|
||||||
|
return scoreContractAnalysis(rec)
|
||||||
|
}
|
||||||
|
case classTelemetry:
|
||||||
|
switch stem {
|
||||||
|
case "auto_apply":
|
||||||
|
return scoreAutoApply(rec)
|
||||||
|
case "outcomes":
|
||||||
|
return scoreOutcomes(rec)
|
||||||
|
case "mode_experiments":
|
||||||
|
return scoreModeExperiment(rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scoreExtraction()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildScoredRun composes a complete ScoredRun for persistence.
|
||||||
|
// Caller supplies recorded_at + the source file path/line offset.
|
||||||
|
// SigHash is computed deterministically from the EvidenceRecord
|
||||||
|
// JSON; ScoredRun traces to the materialized evidence row.
|
||||||
|
func BuildScoredRun(rec EvidenceRecord, sourceFile string, lineOffset int64, recordedAt string) (ScoredRun, error) {
|
||||||
|
out := ScoreRecord(rec)
|
||||||
|
sig, err := canonicalSha256(rec)
|
||||||
|
if err != nil {
|
||||||
|
return ScoredRun{}, fmt.Errorf("scoredrun sig hash: %w", err)
|
||||||
|
}
|
||||||
|
return ScoredRun{
|
||||||
|
SchemaVersion: ScoredRunSchemaVersion,
|
||||||
|
EvidenceRunID: rec.RunID,
|
||||||
|
EvidenceTaskID: rec.TaskID,
|
||||||
|
Category: out.Category,
|
||||||
|
Reasons: out.Reasons,
|
||||||
|
ScoredAt: recordedAt,
|
||||||
|
ScorerVersion: ScorerVersion,
|
||||||
|
SubScores: out.SubScores,
|
||||||
|
Provenance: Provenance{
|
||||||
|
SourceFile: sourceFile,
|
||||||
|
LineOffset: lineOffset,
|
||||||
|
SigHash: sig,
|
||||||
|
RecordedAt: recordedAt,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalSha256 hashes a value's canonical JSON encoding. Used
|
||||||
|
// for ScoredRun.Provenance.SigHash. Matches the Rust pattern of
|
||||||
|
// "hash the structured object, not the raw source bytes" so
|
||||||
|
// re-materialization with same logic produces same hash.
|
||||||
|
func canonicalSha256(v any) (string, error) {
|
||||||
|
bs, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(bs)
|
||||||
|
return hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Class A: verdict-bearing ────────────────────────────────────
|
||||||
|
|
||||||
|
func scoreScrumReview(r EvidenceRecord) ScoreOutput {
|
||||||
|
subs := &SubScores{}
|
||||||
|
successMarker := findPrefix(r.SuccessMarkers, "accepted_on_attempt_")
|
||||||
|
if successMarker == "" {
|
||||||
|
return ScoreOutput{
|
||||||
|
Category: CategoryNeedsHumanReview,
|
||||||
|
Reasons: []string{"scrum_review missing accepted_on_attempt_* success marker"},
|
||||||
|
SubScores: subs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attemptStr := strings.TrimPrefix(successMarker, "accepted_on_attempt_")
|
||||||
|
attempt, err := strconv.Atoi(attemptStr)
|
||||||
|
if err != nil {
|
||||||
|
return ScoreOutput{
|
||||||
|
Category: CategoryNeedsHumanReview,
|
||||||
|
Reasons: []string{"scrum_review accepted_on_attempt_* marker has non-integer suffix: " + attemptStr},
|
||||||
|
SubScores: subs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subs.AcceptedOnAttempt = &attempt
|
||||||
|
switch {
|
||||||
|
case attempt == 1:
|
||||||
|
return ScoreOutput{
|
||||||
|
Category: CategoryAccepted,
|
||||||
|
Reasons: []string{"scrum: accepted on first attempt"},
|
||||||
|
SubScores: subs,
|
||||||
|
}
|
||||||
|
case attempt <= 3:
|
||||||
|
return ScoreOutput{
|
||||||
|
Category: CategoryPartiallyAccepted,
|
||||||
|
Reasons: []string{fmt.Sprintf("scrum: accepted after %d attempts", attempt)},
|
||||||
|
SubScores: subs,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ScoreOutput{
|
||||||
|
Category: CategoryPartiallyAccepted,
|
||||||
|
Reasons: []string{fmt.Sprintf("scrum: accepted only after %d attempts (high-cost path)", attempt)},
|
||||||
|
SubScores: subs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreObserverReview(r EvidenceRecord) ScoreOutput {
|
||||||
|
subs := &SubScores{}
|
||||||
|
switch r.ObserverVerdict {
|
||||||
|
case VerdictAccept:
|
||||||
|
subs.ObserverVerdict = VerdictAccept
|
||||||
|
return ScoreOutput{
|
||||||
|
Category: CategoryAccepted,
|
||||||
|
Reasons: []string{"observer accepted the reviewed attempt"},
|
||||||
|
SubScores: subs,
|
||||||
|
}
|
||||||
|
case VerdictReject:
|
||||||
|
subs.ObserverVerdict = VerdictReject
|
||||||
|
return ScoreOutput{
|
||||||
|
Category: CategoryRejected,
|
||||||
|
Reasons: []string{"observer rejected the reviewed attempt"},
|
||||||
|
SubScores: subs,
|
||||||
|
}
|
||||||
|
case VerdictCycle:
|
||||||
|
subs.ObserverVerdict = VerdictCycle
|
||||||
|
return ScoreOutput{
|
||||||
|
Category: CategoryPartiallyAccepted,
|
||||||
|
Reasons: []string{"observer flagged the attempt as cycling — partial signal"},
|
||||||
|
SubScores: subs,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ScoreOutput{
|
||||||
|
Category: CategoryNeedsHumanReview,
|
||||||
|
Reasons: []string{fmt.Sprintf("observer_verdict missing or unrecognized: %q", r.ObserverVerdict)},
|
||||||
|
SubScores: subs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreAudit(r EvidenceRecord) ScoreOutput {
|
||||||
|
subs := &SubScores{}
|
||||||
|
succ := r.SuccessMarkers
|
||||||
|
fail := r.FailureMarkers
|
||||||
|
|
||||||
|
// Legacy markers (back-compat with pre-fix materializations).
|
||||||
|
if contains(succ, "approved") {
|
||||||
|
return ScoreOutput{Category: CategoryAccepted,
|
||||||
|
Reasons: []string{"audit overall=approved (legacy marker)"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
if contains(fail, "blocked") {
|
||||||
|
return ScoreOutput{Category: CategoryRejected,
|
||||||
|
Reasons: []string{"audit overall=block (legacy marker)"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
if contains(fail, "request_changes") {
|
||||||
|
return ScoreOutput{Category: CategoryPartiallyAccepted,
|
||||||
|
Reasons: []string{"audit overall=request_changes (legacy marker)"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Severity-derived markers (Phase 2 transform).
|
||||||
|
sevSucc := findPrefix(succ, "audit_severity_")
|
||||||
|
sevFail := findPrefix(fail, "audit_severity_")
|
||||||
|
if sevSucc != "" {
|
||||||
|
return ScoreOutput{Category: CategoryAccepted,
|
||||||
|
Reasons: []string{sevSucc + " → minor finding"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
if sevFail == "audit_severity_medium" {
|
||||||
|
return ScoreOutput{Category: CategoryPartiallyAccepted,
|
||||||
|
Reasons: []string{"audit_severity_medium → finding warrants review"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
if sevFail == "audit_severity_high" || sevFail == "audit_severity_critical" {
|
||||||
|
return ScoreOutput{Category: CategoryRejected,
|
||||||
|
Reasons: []string{sevFail + " → blocking finding"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
return ScoreOutput{Category: CategoryNeedsHumanReview,
|
||||||
|
Reasons: []string{"audit row has no severity or overall marker"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreContractAnalysis(r EvidenceRecord) ScoreOutput {
|
||||||
|
subs := &SubScores{}
|
||||||
|
// failure_markers takes precedence: explicit rejection beats absent verdict.
|
||||||
|
if contains(r.FailureMarkers, "observer_rejected") || r.ObserverVerdict == VerdictReject {
|
||||||
|
subs.ObserverVerdict = VerdictReject
|
||||||
|
return ScoreOutput{Category: CategoryRejected,
|
||||||
|
Reasons: []string{"contract analysis: observer rejected"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
switch r.ObserverVerdict {
|
||||||
|
case VerdictAccept:
|
||||||
|
subs.ObserverVerdict = VerdictAccept
|
||||||
|
return ScoreOutput{Category: CategoryAccepted,
|
||||||
|
Reasons: []string{"contract analysis: observer accepted"}, SubScores: subs}
|
||||||
|
case VerdictCycle:
|
||||||
|
subs.ObserverVerdict = VerdictCycle
|
||||||
|
return ScoreOutput{Category: CategoryPartiallyAccepted,
|
||||||
|
Reasons: []string{"contract analysis: observer cycled (partial)"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
return ScoreOutput{Category: CategoryNeedsHumanReview,
|
||||||
|
Reasons: []string{"contract analysis: no observer verdict signal"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Class B: telemetry-rich ─────────────────────────────────────
|
||||||
|
|
||||||
|
func scoreAutoApply(r EvidenceRecord) ScoreOutput {
|
||||||
|
subs := &SubScores{}
|
||||||
|
if contains(r.SuccessMarkers, "committed") {
|
||||||
|
t := true
|
||||||
|
subs.CargoGreen = &t
|
||||||
|
return ScoreOutput{Category: CategoryAccepted,
|
||||||
|
Reasons: []string{"auto_apply: patch committed (cargo green + warning baseline + rationale alignment passed)"},
|
||||||
|
SubScores: subs}
|
||||||
|
}
|
||||||
|
reverted := findContaining(r.FailureMarkers, "reverted")
|
||||||
|
if reverted != "" {
|
||||||
|
if strings.Contains(reverted, "build_red") {
|
||||||
|
f := false
|
||||||
|
subs.CargoGreen = &f
|
||||||
|
}
|
||||||
|
return ScoreOutput{Category: CategoryRejected,
|
||||||
|
Reasons: []string{"auto_apply: " + reverted}, SubScores: subs}
|
||||||
|
}
|
||||||
|
return ScoreOutput{Category: CategoryNeedsHumanReview,
|
||||||
|
Reasons: []string{"auto_apply: no commit + no revert (likely no_patches or dry_run)"},
|
||||||
|
SubScores: subs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreOutcomes(r EvidenceRecord) ScoreOutput {
|
||||||
|
subs := &SubScores{}
|
||||||
|
if contains(r.SuccessMarkers, "all_events_ok") {
|
||||||
|
return ScoreOutput{Category: CategoryAccepted,
|
||||||
|
Reasons: []string{"outcomes: all events ok"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
if gap := numericFromMap(r.ValidationResults, "gap_signals"); gap > 0 {
|
||||||
|
return ScoreOutput{Category: CategoryPartiallyAccepted,
|
||||||
|
Reasons: []string{fmt.Sprintf("outcomes: %d gap signal(s) detected", int(gap))},
|
||||||
|
SubScores: subs}
|
||||||
|
}
|
||||||
|
return ScoreOutput{Category: CategoryNeedsHumanReview,
|
||||||
|
Reasons: []string{"outcomes: no decisive marker — defer to human"},
|
||||||
|
SubScores: subs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreModeExperiment(r EvidenceRecord) ScoreOutput {
|
||||||
|
subs := &SubScores{}
|
||||||
|
if strings.TrimSpace(r.Text) == "" {
|
||||||
|
return ScoreOutput{Category: CategoryRejected,
|
||||||
|
Reasons: []string{"mode_experiment: empty response text"}, SubScores: subs}
|
||||||
|
}
|
||||||
|
if r.LatencyMs > 120_000 {
|
||||||
|
return ScoreOutput{Category: CategoryPartiallyAccepted,
|
||||||
|
Reasons: []string{fmt.Sprintf("mode_experiment: latency %dms exceeds 2-minute soft cap", r.LatencyMs)},
|
||||||
|
SubScores: subs}
|
||||||
|
}
|
||||||
|
return ScoreOutput{Category: CategoryNeedsHumanReview,
|
||||||
|
Reasons: []string{"mode_experiment: response present, latency within bounds; verdict not yet wired"},
|
||||||
|
SubScores: subs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Class C: pure-extraction ────────────────────────────────────
|
||||||
|
|
||||||
|
func scoreExtraction() ScoreOutput {
|
||||||
|
return ScoreOutput{
|
||||||
|
Category: CategoryNeedsHumanReview,
|
||||||
|
Reasons: []string{"extraction-class source has no native scoring signal — JOIN to parent verdict pending Phase 3 v2"},
|
||||||
|
SubScores: &SubScores{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func contains(slice []string, want string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if s == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPrefix(slice []string, prefix string) string {
|
||||||
|
for _, s := range slice {
|
||||||
|
if strings.HasPrefix(s, prefix) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findContaining(slice []string, sub string) string {
|
||||||
|
for _, s := range slice {
|
||||||
|
if strings.Contains(s, sub) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func numericFromMap(m map[string]any, key string) float64 {
|
||||||
|
if m == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return float64(n)
|
||||||
|
case int64:
|
||||||
|
return float64(n)
|
||||||
|
case float32:
|
||||||
|
return float64(n)
|
||||||
|
case float64:
|
||||||
|
return n
|
||||||
|
case json.Number:
|
||||||
|
f, _ := n.Float64()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
package distillation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mkRecord(sourceFile string) EvidenceRecord {
|
||||||
|
return EvidenceRecord{
|
||||||
|
RunID: "run-1",
|
||||||
|
TaskID: "task-1",
|
||||||
|
Timestamp: "2026-04-29T12:00:00Z",
|
||||||
|
SchemaVersion: EvidenceSchemaVersion,
|
||||||
|
Provenance: Provenance{
|
||||||
|
SourceFile: sourceFile,
|
||||||
|
SigHash: "deadbeef",
|
||||||
|
RecordedAt: "2026-04-29T12:00:01Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceClassFor(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want sourceClass
|
||||||
|
}{
|
||||||
|
{"data/_kb/scrum_reviews.jsonl", classVerdict},
|
||||||
|
{"data/_kb/observer_reviews.jsonl", classVerdict},
|
||||||
|
{"data/_kb/audits.jsonl", classVerdict},
|
||||||
|
{"data/_kb/contract_analyses.jsonl", classVerdict},
|
||||||
|
{"data/_kb/auto_apply.jsonl", classTelemetry},
|
||||||
|
{"data/_kb/outcomes.jsonl", classTelemetry},
|
||||||
|
{"data/_kb/mode_experiments.jsonl", classTelemetry},
|
||||||
|
{"data/_kb/distilled_facts.jsonl", classExtraction},
|
||||||
|
{"data/_kb/audit_facts.jsonl", classExtraction},
|
||||||
|
{"data/_kb/observer_escalations.jsonl", classExtraction},
|
||||||
|
{"data/_kb/wholly_unknown.jsonl", classExtraction}, // unknown → extraction (conservative)
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := sourceClassFor(c.path)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("sourceClassFor(%q): want %q, got %q", c.path, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreScrumReview(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
successMarkers []string
|
||||||
|
wantCategory ScoreCategory
|
||||||
|
wantReasonSub string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "first attempt → accepted",
|
||||||
|
successMarkers: []string{"accepted_on_attempt_1"},
|
||||||
|
wantCategory: CategoryAccepted,
|
||||||
|
wantReasonSub: "first attempt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "second attempt → partial",
|
||||||
|
successMarkers: []string{"accepted_on_attempt_2"},
|
||||||
|
wantCategory: CategoryPartiallyAccepted,
|
||||||
|
wantReasonSub: "after 2 attempts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fourth attempt → partial (high-cost)",
|
||||||
|
successMarkers: []string{"accepted_on_attempt_4"},
|
||||||
|
wantCategory: CategoryPartiallyAccepted,
|
||||||
|
wantReasonSub: "high-cost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing marker → needs_human_review",
|
||||||
|
successMarkers: []string{},
|
||||||
|
wantCategory: CategoryNeedsHumanReview,
|
||||||
|
wantReasonSub: "missing accepted_on_attempt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
rec := mkRecord("data/_kb/scrum_reviews.jsonl")
|
||||||
|
rec.SuccessMarkers = c.successMarkers
|
||||||
|
out := ScoreRecord(rec)
|
||||||
|
if out.Category != c.wantCategory {
|
||||||
|
t.Errorf("category: want %q, got %q (reasons=%v)", c.wantCategory, out.Category, out.Reasons)
|
||||||
|
}
|
||||||
|
if !reasonsContain(out.Reasons, c.wantReasonSub) {
|
||||||
|
t.Errorf("reasons missing %q: %v", c.wantReasonSub, out.Reasons)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreObserverReview(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
verdict ObserverVerdict
|
||||||
|
want ScoreCategory
|
||||||
|
}{
|
||||||
|
{VerdictAccept, CategoryAccepted},
|
||||||
|
{VerdictReject, CategoryRejected},
|
||||||
|
{VerdictCycle, CategoryPartiallyAccepted},
|
||||||
|
{"", CategoryNeedsHumanReview},
|
||||||
|
{"weird-verdict", CategoryNeedsHumanReview},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
rec := mkRecord("data/_kb/observer_reviews.jsonl")
|
||||||
|
rec.ObserverVerdict = c.verdict
|
||||||
|
out := ScoreRecord(rec)
|
||||||
|
if out.Category != c.want {
|
||||||
|
t.Errorf("verdict=%q: want %q, got %q", c.verdict, c.want, out.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreAudit_LegacyAndSeverityMarkers(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
succ []string
|
||||||
|
fail []string
|
||||||
|
want ScoreCategory
|
||||||
|
}{
|
||||||
|
{"legacy approved", []string{"approved"}, nil, CategoryAccepted},
|
||||||
|
{"legacy blocked", nil, []string{"blocked"}, CategoryRejected},
|
||||||
|
{"legacy request_changes", nil, []string{"request_changes"}, CategoryPartiallyAccepted},
|
||||||
|
{"severity_low → accepted", []string{"audit_severity_low"}, nil, CategoryAccepted},
|
||||||
|
{"severity_info → accepted", []string{"audit_severity_info"}, nil, CategoryAccepted},
|
||||||
|
{"severity_medium fail → partial", nil, []string{"audit_severity_medium"}, CategoryPartiallyAccepted},
|
||||||
|
{"severity_high → rejected", nil, []string{"audit_severity_high"}, CategoryRejected},
|
||||||
|
{"severity_critical → rejected", nil, []string{"audit_severity_critical"}, CategoryRejected},
|
||||||
|
{"no markers", nil, nil, CategoryNeedsHumanReview},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
rec := mkRecord("data/_kb/audits.jsonl")
|
||||||
|
rec.SuccessMarkers = c.succ
|
||||||
|
rec.FailureMarkers = c.fail
|
||||||
|
out := ScoreRecord(rec)
|
||||||
|
if out.Category != c.want {
|
||||||
|
t.Errorf("want %q, got %q (reasons=%v)", c.want, out.Category, out.Reasons)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreAutoApply(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
succ []string
|
||||||
|
fail []string
|
||||||
|
want ScoreCategory
|
||||||
|
}{
|
||||||
|
{"committed → accepted", []string{"committed"}, nil, CategoryAccepted},
|
||||||
|
{"reverted_build_red → rejected", nil, []string{"reverted_build_red"}, CategoryRejected},
|
||||||
|
{"reverted other → rejected", nil, []string{"reverted_warning_count_up"}, CategoryRejected},
|
||||||
|
{"no signal → needs_human", nil, nil, CategoryNeedsHumanReview},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
rec := mkRecord("data/_kb/auto_apply.jsonl")
|
||||||
|
rec.SuccessMarkers = c.succ
|
||||||
|
rec.FailureMarkers = c.fail
|
||||||
|
out := ScoreRecord(rec)
|
||||||
|
if out.Category != c.want {
|
||||||
|
t.Errorf("want %q, got %q", c.want, out.Category)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreOutcomes(t *testing.T) {
|
||||||
|
rec := mkRecord("data/_kb/outcomes.jsonl")
|
||||||
|
rec.SuccessMarkers = []string{"all_events_ok"}
|
||||||
|
if out := ScoreRecord(rec); out.Category != CategoryAccepted {
|
||||||
|
t.Errorf("all_events_ok: want accepted, got %q", out.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec2 := mkRecord("data/_kb/outcomes.jsonl")
|
||||||
|
rec2.ValidationResults = map[string]any{"gap_signals": float64(2)}
|
||||||
|
if out := ScoreRecord(rec2); out.Category != CategoryPartiallyAccepted {
|
||||||
|
t.Errorf("gap_signals=2: want partial, got %q (reasons=%v)", out.Category, out.Reasons)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec3 := mkRecord("data/_kb/outcomes.jsonl")
|
||||||
|
if out := ScoreRecord(rec3); out.Category != CategoryNeedsHumanReview {
|
||||||
|
t.Errorf("no signal: want needs_human, got %q", out.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreModeExperiment(t *testing.T) {
|
||||||
|
rec := mkRecord("data/_kb/mode_experiments.jsonl")
|
||||||
|
rec.Text = ""
|
||||||
|
if out := ScoreRecord(rec); out.Category != CategoryRejected {
|
||||||
|
t.Errorf("empty text: want rejected, got %q", out.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.Text = "real response"
|
||||||
|
rec.LatencyMs = 130_000
|
||||||
|
if out := ScoreRecord(rec); out.Category != CategoryPartiallyAccepted {
|
||||||
|
t.Errorf("over latency cap: want partial, got %q", out.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.LatencyMs = 5000
|
||||||
|
if out := ScoreRecord(rec); out.Category != CategoryNeedsHumanReview {
|
||||||
|
t.Errorf("normal: want needs_human (verdict not yet wired), got %q", out.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreExtraction_Defaults(t *testing.T) {
|
||||||
|
for _, src := range []string{
|
||||||
|
"data/_kb/distilled_facts.jsonl",
|
||||||
|
"data/_kb/distilled_procedures.jsonl",
|
||||||
|
"data/_kb/audit_facts.jsonl",
|
||||||
|
"data/_kb/observer_escalations.jsonl",
|
||||||
|
} {
|
||||||
|
rec := mkRecord(src)
|
||||||
|
out := ScoreRecord(rec)
|
||||||
|
if out.Category != CategoryNeedsHumanReview {
|
||||||
|
t.Errorf("%s: want needs_human_review, got %q", src, out.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contamination firewall — the safety-critical guarantee ───────
|
||||||
|
|
||||||
|
func TestValidateSftSample_RejectsContaminationCategories(t *testing.T) {
|
||||||
|
for _, contaminated := range []SftQualityScore{
|
||||||
|
SftQualityScore("rejected"),
|
||||||
|
SftQualityScore("needs_human_review"),
|
||||||
|
} {
|
||||||
|
s := goodSftSample()
|
||||||
|
s.QualityScore = contaminated
|
||||||
|
err := ValidateSftSample(s)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("contaminated quality_score=%q should fail validation", contaminated)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrSftContamination) {
|
||||||
|
t.Errorf("contaminated %q: want errors.Is(err, ErrSftContamination), got %v", contaminated, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSftSample_AcceptsLegalCategories(t *testing.T) {
|
||||||
|
for _, legal := range []SftQualityScore{SftQualityAccepted, SftQualityPartiallyAccepted} {
|
||||||
|
s := goodSftSample()
|
||||||
|
s.QualityScore = legal
|
||||||
|
if err := ValidateSftSample(s); err != nil {
|
||||||
|
t.Errorf("legal quality_score=%q failed: %v", legal, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSftSample_RejectsTypoCategory(t *testing.T) {
|
||||||
|
s := goodSftSample()
|
||||||
|
s.QualityScore = "approved" // close to "accepted" but wrong
|
||||||
|
err := ValidateSftSample(s)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("typo category should fail validation")
|
||||||
|
}
|
||||||
|
// Typo is NOT contamination — should be a regular ValidationError,
|
||||||
|
// not the firewall sentinel. This distinguishes "you typo'd" from
|
||||||
|
// "you broke the spec."
|
||||||
|
if errors.Is(err, ErrSftContamination) {
|
||||||
|
t.Error("typo should not surface as ErrSftContamination")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSftSample_RejectsEmptyPair(t *testing.T) {
|
||||||
|
s := goodSftSample()
|
||||||
|
s.Instruction = " "
|
||||||
|
if err := ValidateSftSample(s); err == nil {
|
||||||
|
t.Error("whitespace-only instruction should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
s2 := goodSftSample()
|
||||||
|
s2.Response = ""
|
||||||
|
if err := ValidateSftSample(s2); err == nil {
|
||||||
|
t.Error("empty response should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateScoredRun_ReasonsRequired(t *testing.T) {
|
||||||
|
r := ScoredRun{
|
||||||
|
SchemaVersion: ScoredRunSchemaVersion,
|
||||||
|
EvidenceRunID: "x",
|
||||||
|
EvidenceTaskID: "y",
|
||||||
|
Category: CategoryAccepted,
|
||||||
|
Reasons: nil, // empty — must fail
|
||||||
|
ScoredAt: "2026-04-29T12:00:00Z",
|
||||||
|
ScorerVersion: ScorerVersion,
|
||||||
|
Provenance: Provenance{
|
||||||
|
SourceFile: "data/_kb/scrum_reviews.jsonl",
|
||||||
|
SigHash: "abc",
|
||||||
|
RecordedAt: "2026-04-29T12:00:00Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := ValidateScoredRun(r)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("empty reasons should fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "reasons") {
|
||||||
|
t.Errorf("error should mention reasons: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildScoredRun_DeterministicSigHash(t *testing.T) {
|
||||||
|
rec := mkRecord("data/_kb/scrum_reviews.jsonl")
|
||||||
|
rec.SuccessMarkers = []string{"accepted_on_attempt_1"}
|
||||||
|
|
||||||
|
r1, err := BuildScoredRun(rec, "data/scored-runs/2026/04/29/x.jsonl", 0, "2026-04-29T12:00:00Z")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r2, err := BuildScoredRun(rec, "data/scored-runs/2026/04/29/x.jsonl", 0, "2026-04-29T12:00:00Z")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r1.Provenance.SigHash != r2.Provenance.SigHash {
|
||||||
|
t.Errorf("identical EvidenceRecord should produce identical sig_hash: %s vs %s",
|
||||||
|
r1.Provenance.SigHash, r2.Provenance.SigHash)
|
||||||
|
}
|
||||||
|
if r1.Category != CategoryAccepted {
|
||||||
|
t.Errorf("scored category: %q", r1.Category)
|
||||||
|
}
|
||||||
|
if r1.ScorerVersion != ScorerVersion {
|
||||||
|
t.Errorf("scorer version stamped wrong: %q", r1.ScorerVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreRecord_PureFunction_NoMutationOfInput(t *testing.T) {
|
||||||
|
// Belt-and-braces: the contract says "NO mutable state, identical
|
||||||
|
// input → identical output forever." Verify by scoring the same
|
||||||
|
// record twice and ensuring the input hasn't been touched.
|
||||||
|
rec := mkRecord("data/_kb/scrum_reviews.jsonl")
|
||||||
|
rec.SuccessMarkers = []string{"accepted_on_attempt_2"}
|
||||||
|
original := rec
|
||||||
|
out1 := ScoreRecord(rec)
|
||||||
|
out2 := ScoreRecord(rec)
|
||||||
|
if rec.RunID != original.RunID || len(rec.SuccessMarkers) != 1 {
|
||||||
|
t.Error("ScoreRecord mutated its input")
|
||||||
|
}
|
||||||
|
if out1.Category != out2.Category {
|
||||||
|
t.Error("ScoreRecord is non-deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func goodSftSample() SftSample {
|
||||||
|
return SftSample{
|
||||||
|
SchemaVersion: SftSampleSchemaVersion,
|
||||||
|
ID: "sft-1",
|
||||||
|
Instruction: "summarize the diff",
|
||||||
|
Context: "diff body...",
|
||||||
|
Response: "the diff adds a function",
|
||||||
|
SourceRunID: "run-1",
|
||||||
|
QualityScore: SftQualityAccepted,
|
||||||
|
CreatedAt: "2026-04-29T12:00:00Z",
|
||||||
|
Provenance: Provenance{
|
||||||
|
SourceFile: "data/scored-runs/2026/04/29/x.jsonl",
|
||||||
|
SigHash: "deadbeef",
|
||||||
|
RecordedAt: "2026-04-29T12:00:01Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reasonsContain(reasons []string, sub string) bool {
|
||||||
|
for _, r := range reasons {
|
||||||
|
if strings.Contains(r, sub) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package distillation
|
||||||
|
|
||||||
|
// SFT (Supervised Fine-Tuning) export pipeline. Closes the SUBSTRATE
|
||||||
|
// half of OPEN #2 — types, contamination firewall, file-listing
|
||||||
|
// helper. The actual synthesis (turning EvidenceRecord + ScoredRun
|
||||||
|
// into instruction/input/response triples) is still on the Rust
|
||||||
|
// side at scripts/distillation/export_sft.ts and will land in a
|
||||||
|
// follow-up wave.
|
||||||
|
//
|
||||||
|
// Why ship substrate without synthesis: the firewall constants and
|
||||||
|
// types are the load-bearing contamination guarantees. Once they're
|
||||||
|
// pinned in Go (with tests proving the firewall set is exactly
|
||||||
|
// {rejected, needs_human_review} and never expands), the synthesis
|
||||||
|
// port becomes a translation exercise rather than a design one.
|
||||||
|
//
|
||||||
|
// Per the project_distillation_substrate.md note: SFT_NEVER is one
|
||||||
|
// of the "what NOT to touch casually" knobs. Replicating it here in
|
||||||
|
// Go preserves the cross-runtime invariant — the contamination
|
||||||
|
// firewall fires even if the SFT export is run from the Go side.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SftNever is declared in types.go (the load-bearing contamination
|
||||||
|
// firewall — pinned at the type-level so every consumer reads the
|
||||||
|
// same source of truth). IsSftNever below is the predicate
|
||||||
|
// helper; it lives here because it's specific to the SFT export
|
||||||
|
// path, not a property of the type system.
|
||||||
|
//
|
||||||
|
// IsSftNever returns true if a scored run's category is on the
|
||||||
|
// contamination firewall list. Inlinable; called per-record in the
|
||||||
|
// hot synthesis loop.
|
||||||
|
func IsSftNever(c ScoreCategory) bool {
|
||||||
|
for _, blocked := range SftNever {
|
||||||
|
if c == blocked {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportSftOptions mirrors the TS shape so callers porting from
|
||||||
|
// Rust have an identity-translation surface. Root is the lakehouse
|
||||||
|
// data root (default $LH_DISTILL_ROOT or /home/profit/lakehouse).
|
||||||
|
// RecordedAt is the timestamp stamped on emitted SFT samples for
|
||||||
|
// lineage. IncludePartial toggles "emit even when evidence record
|
||||||
|
// is missing some optional fields"; DryRun skips file writes.
|
||||||
|
type ExportSftOptions struct {
|
||||||
|
Root string
|
||||||
|
RecordedAt string
|
||||||
|
IncludePartial bool
|
||||||
|
DryRun bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportSftResult mirrors the TS result shape exactly so a
|
||||||
|
// callable swap between sides doesn't break consumers reading the
|
||||||
|
// JSON.
|
||||||
|
type ExportSftResult struct {
|
||||||
|
ScoredFilesRead int `json:"scored_files_read"`
|
||||||
|
RecordsRead int `json:"records_read"`
|
||||||
|
RecordsExported int `json:"records_exported"`
|
||||||
|
RecordsQuarantined int `json:"records_quarantined"`
|
||||||
|
OutputPath string `json:"output_path"`
|
||||||
|
QuarantineSummary string `json:"quarantine_summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListScoredRunFiles walks {root}/data/scored-runs/YYYY/MM/DD/*.jsonl
|
||||||
|
// and returns the sorted list. Empty when the dir doesn't exist
|
||||||
|
// (matches Rust behavior — caller should treat zero-files as a
|
||||||
|
// no-op, not an error).
|
||||||
|
func ListScoredRunFiles(root string) ([]string, error) {
|
||||||
|
if root == "" {
|
||||||
|
return nil, errors.New("distillation: empty root")
|
||||||
|
}
|
||||||
|
base := filepath.Join(root, "data", "scored-runs")
|
||||||
|
if _, err := os.Stat(base); os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat %s: %w", base, err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
years, err := os.ReadDir(base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", base, err)
|
||||||
|
}
|
||||||
|
sortDirEntries(years)
|
||||||
|
for _, y := range years {
|
||||||
|
if !y.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
months, err := os.ReadDir(filepath.Join(base, y.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sortDirEntries(months)
|
||||||
|
for _, m := range months {
|
||||||
|
if !m.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
days, err := os.ReadDir(filepath.Join(base, y.Name(), m.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sortDirEntries(days)
|
||||||
|
for _, d := range days {
|
||||||
|
if !d.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files, err := os.ReadDir(filepath.Join(base, y.Name(), m.Name(), d.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sortDirEntries(files)
|
||||||
|
for _, f := range files {
|
||||||
|
if strings.HasSuffix(f.Name(), ".jsonl") {
|
||||||
|
out = append(out, filepath.Join(base, y.Name(), m.Name(), d.Name(), f.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortDirEntries sorts dir entries by name in-place. Stable
|
||||||
|
// alphabetical so the directory walk is deterministic — important
|
||||||
|
// for the audit_baselines longitudinal signal which expects the
|
||||||
|
// same order across runs.
|
||||||
|
func sortDirEntries(entries []os.DirEntry) {
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadScoredRunsFromFile reads a JSONL of ScoredRun records.
|
||||||
|
// Returns the slice + the count of malformed lines (skipped).
|
||||||
|
// This is the read-half — the synthesis half (turn ScoredRun +
|
||||||
|
// EvidenceRecord into SftSample) is the not-yet-ported piece.
|
||||||
|
func LoadScoredRunsFromFile(path string) ([]ScoredRun, int, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
out := make([]ScoredRun, 0, len(lines))
|
||||||
|
skipped := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var sr ScoredRun
|
||||||
|
if err := json.Unmarshal([]byte(line), &sr); err != nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, sr)
|
||||||
|
}
|
||||||
|
return out, skipped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadEvidenceByRunID reads {evidence_path}'s JSONL and returns a
|
||||||
|
// map[run_id]EvidenceRecord. evidence_path is derived by replacing
|
||||||
|
// /scored-runs/ with /evidence/ in the scored-runs path — the
|
||||||
|
// convention the Rust pipeline uses to colocate evidence + scores.
|
||||||
|
//
|
||||||
|
// Cache is the per-call memoization map: callers iterating multiple
|
||||||
|
// scored-runs files in the same directory benefit from shared
|
||||||
|
// loads. Pass nil to disable caching (useful in tests).
|
||||||
|
func LoadEvidenceByRunID(scoredPath string, cache map[string]map[string]EvidenceRecord) (map[string]EvidenceRecord, error) {
|
||||||
|
evidencePath := strings.Replace(scoredPath, "/scored-runs/", "/evidence/", 1)
|
||||||
|
if cache != nil {
|
||||||
|
if hit, ok := cache[evidencePath]; ok {
|
||||||
|
return hit, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make(map[string]EvidenceRecord)
|
||||||
|
data, err := os.ReadFile(evidencePath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if cache != nil {
|
||||||
|
cache[evidencePath] = out
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var r EvidenceRecord
|
||||||
|
if err := json.Unmarshal([]byte(line), &r); err != nil {
|
||||||
|
continue // tolerate malformed lines per Rust convention
|
||||||
|
}
|
||||||
|
out[r.RunID] = r
|
||||||
|
}
|
||||||
|
if cache != nil {
|
||||||
|
cache[evidencePath] = out
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SynthesizeSft turns a (ScoredRun, EvidenceRecord) pair into an
|
||||||
|
// SftSample. Returns nil when the record is unsuitable for SFT:
|
||||||
|
// - model_role isn't executor/reviewer/applier (extraction-class
|
||||||
|
// records have no instruction→response shape)
|
||||||
|
// - text is empty (nothing to learn from)
|
||||||
|
//
|
||||||
|
// Mirrors the Rust synthesizeSft exactly so byte-identical SFT
|
||||||
|
// JSONL output is achievable across both runtimes (operators
|
||||||
|
// running a/b validation between Rust + Go pipelines depend on this).
|
||||||
|
func SynthesizeSft(scored ScoredRun, ev EvidenceRecord, recordedAt, sftID string) *SftSample {
|
||||||
|
if ev.ModelRole != RoleExecutor && ev.ModelRole != RoleReviewer && ev.ModelRole != RoleApplier {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
text := ev.Text
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stem := stemFromSourceFile(ev.Provenance.SourceFile)
|
||||||
|
firstSourceFile := ""
|
||||||
|
if len(ev.SourceFiles) > 0 {
|
||||||
|
firstSourceFile = ev.SourceFiles[0]
|
||||||
|
}
|
||||||
|
if firstSourceFile == "" {
|
||||||
|
firstSourceFile = "<file>"
|
||||||
|
}
|
||||||
|
instruction := buildInstruction(stem, ev.TaskID, firstSourceFile)
|
||||||
|
|
||||||
|
// Context — what the model could see. Keep terse; matches Rust's
|
||||||
|
// space-separated " · " join.
|
||||||
|
var ctxParts []string
|
||||||
|
if ev.RetrievedContext != nil {
|
||||||
|
if len(ev.RetrievedContext.MatrixCorpora) > 0 {
|
||||||
|
ctxParts = append(ctxParts, "matrix="+strings.Join(ev.RetrievedContext.MatrixCorpora, ","))
|
||||||
|
}
|
||||||
|
if ev.RetrievedContext.PathwayFingerprintsSeen > 0 {
|
||||||
|
ctxParts = append(ctxParts, fmt.Sprintf("pathway_fingerprints=%d", ev.RetrievedContext.PathwayFingerprintsSeen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ev.ModelName != "" {
|
||||||
|
ctxParts = append(ctxParts, "model="+ev.ModelName)
|
||||||
|
}
|
||||||
|
context := strings.Join(ctxParts, " · ")
|
||||||
|
|
||||||
|
return &SftSample{
|
||||||
|
SchemaVersion: SftSampleSchemaVersion,
|
||||||
|
ID: sftID,
|
||||||
|
Instruction: instruction,
|
||||||
|
Context: context,
|
||||||
|
Response: text,
|
||||||
|
SourceRunID: scored.EvidenceRunID,
|
||||||
|
QualityScore: SftQualityScore(scored.Category),
|
||||||
|
CreatedAt: recordedAt,
|
||||||
|
Provenance: Provenance{
|
||||||
|
SourceFile: scored.Provenance.SourceFile,
|
||||||
|
LineOffset: scored.Provenance.LineOffset,
|
||||||
|
SigHash: scored.Provenance.SigHash,
|
||||||
|
RecordedAt: recordedAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stemFromSourceFile mirrors the Rust path-cleanup: strip the
|
||||||
|
// `data/_kb/` prefix and the `.jsonl` suffix. Used to dispatch
|
||||||
|
// the per-source-class instruction templates below.
|
||||||
|
func stemFromSourceFile(path string) string {
|
||||||
|
s := strings.TrimPrefix(path, "data/_kb/")
|
||||||
|
s = strings.TrimSuffix(s, ".jsonl")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildInstruction returns the per-source-class instruction
|
||||||
|
// template. Mirrors the Rust switch statement byte-for-byte so
|
||||||
|
// trained-model behavior is unchanged across runtimes.
|
||||||
|
func buildInstruction(stem, taskID, firstSourceFile string) string {
|
||||||
|
switch stem {
|
||||||
|
case "scrum_reviews":
|
||||||
|
return fmt.Sprintf("Review the file '%s' against the PRD + change-proposal context. Produce a forensic audit with findings, severity, confidence, patches.", firstSourceFile)
|
||||||
|
case "mode_experiments":
|
||||||
|
return fmt.Sprintf("Run task_class='%s' for file '%s'. Produce the mode-runner's expected output shape.", taskID, firstSourceFile)
|
||||||
|
case "auto_apply":
|
||||||
|
return fmt.Sprintf("Auto-apply: emit a 6-line surgical patch for '%s' based on the latest scrum review findings.", firstSourceFile)
|
||||||
|
case "audits":
|
||||||
|
phase := strings.TrimPrefix(taskID, "phase:")
|
||||||
|
return fmt.Sprintf("Audit phase '%s' and report findings with severity.", phase)
|
||||||
|
case "observer_reviews":
|
||||||
|
return fmt.Sprintf("Observer-review the latest attempt on '%s'. Verdict: accept | reject | cycle.", firstSourceFile)
|
||||||
|
case "contract_analyses":
|
||||||
|
permit := strings.TrimPrefix(taskID, "permit:")
|
||||||
|
// The Rust version reads ev.metadata.contractor when present.
|
||||||
|
// Go's EvidenceRecord doesn't carry a free-form metadata bag
|
||||||
|
// today, so we always emit the no-contractor form. Operators
|
||||||
|
// who need contractor-aware instructions can extend this once
|
||||||
|
// the metadata field lands in EvidenceRecord (separate ADR).
|
||||||
|
return fmt.Sprintf("Analyze permit '%s'. Recommend with risk markers.", permit)
|
||||||
|
case "outcomes":
|
||||||
|
return "Run scenario; report per-event outcome with citations."
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Source '%s' run; produce the appropriate output for this task type.", stem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportSft is the now-COMPLETE port (not just substrate). Walks
|
||||||
|
// scored-runs files, loads paired evidence, applies the firewall,
|
||||||
|
// synthesizes SFT samples for surviving records, writes the
|
||||||
|
// combined JSONL output.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - DryRun=true: counts but doesn't write files.
|
||||||
|
// - IncludePartial=true: emit samples even when synthesis returns
|
||||||
|
// "skip" reasons that aren't on the firewall (model_role
|
||||||
|
// mismatch, empty text). Default false.
|
||||||
|
// - Empty Root or missing scored-runs dir: returns zero-counts,
|
||||||
|
// no error.
|
||||||
|
//
|
||||||
|
// Output JSONL format: one SftSample per line, UTF-8, LF-terminated,
|
||||||
|
// matching the Rust convention so a/b validation between runtimes
|
||||||
|
// can diff the files directly.
|
||||||
|
func ExportSft(opts ExportSftOptions) (ExportSftResult, error) {
|
||||||
|
if opts.RecordedAt == "" {
|
||||||
|
return ExportSftResult{}, errors.New("distillation: ExportSft requires RecordedAt")
|
||||||
|
}
|
||||||
|
res := ExportSftResult{
|
||||||
|
OutputPath: filepath.Join(opts.Root, "data", "distilled", "sft", "sft_export.jsonl"),
|
||||||
|
}
|
||||||
|
files, err := ListScoredRunFiles(opts.Root)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("list scored runs: %w", err)
|
||||||
|
}
|
||||||
|
res.ScoredFilesRead = len(files)
|
||||||
|
|
||||||
|
evCache := make(map[string]map[string]EvidenceRecord)
|
||||||
|
var output []byte
|
||||||
|
skippedNotInstructable := 0
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
runs, _, err := LoadScoredRunsFromFile(f)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
evidence, err := LoadEvidenceByRunID(f, evCache)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.RecordsRead += len(runs)
|
||||||
|
for i, r := range runs {
|
||||||
|
if IsSftNever(r.Category) {
|
||||||
|
res.RecordsQuarantined++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ev, ok := evidence[r.EvidenceRunID]
|
||||||
|
if !ok {
|
||||||
|
// Evidence missing — without it we can't synthesize.
|
||||||
|
// IncludePartial=true would let us emit a sample with
|
||||||
|
// just scored-side fields, but the contract says
|
||||||
|
// SftSample.Response must be non-empty (no evidence
|
||||||
|
// = no text), so skip regardless.
|
||||||
|
skippedNotInstructable++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sftID := fmt.Sprintf("%s:%d", filepath.Base(f), i)
|
||||||
|
sample := SynthesizeSft(r, ev, opts.RecordedAt, sftID)
|
||||||
|
if sample == nil {
|
||||||
|
skippedNotInstructable++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !opts.DryRun {
|
||||||
|
line, err := json.Marshal(sample)
|
||||||
|
if err != nil {
|
||||||
|
skippedNotInstructable++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
output = append(output, line...)
|
||||||
|
output = append(output, '\n')
|
||||||
|
}
|
||||||
|
res.RecordsExported++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.QuarantineSummary = fmt.Sprintf("firewall=%d, not-instructable=%d", res.RecordsQuarantined, skippedNotInstructable)
|
||||||
|
if !opts.DryRun && len(output) > 0 {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(res.OutputPath), 0o755); err != nil {
|
||||||
|
return res, fmt.Errorf("mkdir output: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(res.OutputPath, output, 0o644); err != nil {
|
||||||
|
return res, fmt.Errorf("write output: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package distillation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIsSftNever_Firewall locks the contamination firewall set:
|
||||||
|
// the predicate fires for "rejected" and "needs_human_review" and
|
||||||
|
// no others. Per project_distillation_substrate.md: this is one of
|
||||||
|
// the substrate's load-bearing knobs — touching the firewall set
|
||||||
|
// requires explicit sign-off.
|
||||||
|
func TestIsSftNever_Firewall(t *testing.T) {
|
||||||
|
mustBlock := []ScoreCategory{
|
||||||
|
CategoryRejected,
|
||||||
|
CategoryNeedsHumanReview,
|
||||||
|
}
|
||||||
|
for _, c := range mustBlock {
|
||||||
|
if !IsSftNever(c) {
|
||||||
|
t.Errorf("firewall must block %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Anything else should NOT be blocked. Read every category
|
||||||
|
// constant in this package and assert non-blocked unless it's
|
||||||
|
// in mustBlock.
|
||||||
|
allKnown := []ScoreCategory{
|
||||||
|
CategoryAccepted,
|
||||||
|
CategoryPartiallyAccepted,
|
||||||
|
CategoryRejected,
|
||||||
|
CategoryNeedsHumanReview,
|
||||||
|
}
|
||||||
|
for _, c := range allKnown {
|
||||||
|
shouldBlock := false
|
||||||
|
for _, b := range mustBlock {
|
||||||
|
if c == b {
|
||||||
|
shouldBlock = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := IsSftNever(c); got != shouldBlock {
|
||||||
|
t.Errorf("IsSftNever(%q) = %v, want %v", c, got, shouldBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unknown category is NOT blocked — that's the safe default
|
||||||
|
// (operators bumping ScoreCategory enum should explicitly add
|
||||||
|
// to firewall if they want it gated).
|
||||||
|
if IsSftNever(ScoreCategory("custom_future_category")) {
|
||||||
|
t.Errorf("unknown category must not be blocked by firewall")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSftNever_PinsExpectedSet locks the firewall slice contents.
|
||||||
|
// If a future commit adds or removes categories from SftNever, this
|
||||||
|
// test fails — forcing the change through review.
|
||||||
|
func TestSftNever_PinsExpectedSet(t *testing.T) {
|
||||||
|
want := map[ScoreCategory]bool{
|
||||||
|
CategoryRejected: true,
|
||||||
|
CategoryNeedsHumanReview: true,
|
||||||
|
}
|
||||||
|
if len(SftNever) != len(want) {
|
||||||
|
t.Fatalf("SftNever has %d entries, want %d (firewall set changed without review?)",
|
||||||
|
len(SftNever), len(want))
|
||||||
|
}
|
||||||
|
for _, c := range SftNever {
|
||||||
|
if !want[c] {
|
||||||
|
t.Errorf("SftNever contains %q, which is not in the expected firewall set", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListScoredRunFiles_Empty: missing root → no files, no error.
|
||||||
|
// Matches Rust behavior; operators running ExportSft on a fresh box
|
||||||
|
// shouldn't see an error before any scored runs have landed.
|
||||||
|
func TestListScoredRunFiles_Empty(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
files, err := ListScoredRunFiles(tmp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListScoredRunFiles: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 0 {
|
||||||
|
t.Errorf("empty root: expected 0 files, got %d", len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListScoredRunFiles_WalksYearMonthDay locks the directory walk
|
||||||
|
// pattern: data/scored-runs/YYYY/MM/DD/*.jsonl. Subset of full
|
||||||
|
// Rust-side test coverage but proves the walk visits the right
|
||||||
|
// nesting.
|
||||||
|
func TestListScoredRunFiles_WalksYearMonthDay(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
// Create the expected nested structure.
|
||||||
|
dirs := []string{
|
||||||
|
filepath.Join(tmp, "data", "scored-runs", "2026", "04", "30"),
|
||||||
|
filepath.Join(tmp, "data", "scored-runs", "2026", "05", "01"),
|
||||||
|
}
|
||||||
|
for _, d := range dirs {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop a JSONL in each + a non-JSONL we should skip.
|
||||||
|
for i, d := range dirs {
|
||||||
|
jsonlPath := filepath.Join(d, "run.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte("{}\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", jsonlPath, err)
|
||||||
|
}
|
||||||
|
// Non-JSONL — must be skipped.
|
||||||
|
other := filepath.Join(d, "skip.txt")
|
||||||
|
if err := os.WriteFile(other, []byte("ignore me"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", other, err)
|
||||||
|
}
|
||||||
|
_ = i
|
||||||
|
}
|
||||||
|
files, err := ListScoredRunFiles(tmp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListScoredRunFiles: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 2 {
|
||||||
|
t.Errorf("expected 2 .jsonl files, got %d (%v)", len(files), files)
|
||||||
|
}
|
||||||
|
// Sort order: 2026-04-30 before 2026-05-01. Critical for audit
|
||||||
|
// baselines — the longitudinal signal depends on stable order.
|
||||||
|
if len(files) >= 2 {
|
||||||
|
if files[0] >= files[1] {
|
||||||
|
t.Errorf("files not sorted ascending: %q vs %q", files[0], files[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-JSONL must be skipped.
|
||||||
|
for _, f := range files {
|
||||||
|
if filepath.Ext(f) != ".jsonl" {
|
||||||
|
t.Errorf("listing returned non-.jsonl: %q", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSynthesizeSft_PerSourceClass locks the per-source-class
|
||||||
|
// instruction templates byte-for-byte against the Rust source.
|
||||||
|
// If a future commit changes a template, this test fails — the
|
||||||
|
// trained-model behavior shifts under our feet.
|
||||||
|
func TestSynthesizeSft_PerSourceClass(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
sourceFile string
|
||||||
|
taskID string
|
||||||
|
sourceFiles []string
|
||||||
|
wantPrefix string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"scrum_reviews",
|
||||||
|
"data/_kb/scrum_reviews.jsonl",
|
||||||
|
"any",
|
||||||
|
[]string{"src/foo.rs"},
|
||||||
|
"Review the file 'src/foo.rs' against",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode_experiments",
|
||||||
|
"data/_kb/mode_experiments.jsonl",
|
||||||
|
"task_42",
|
||||||
|
[]string{"src/bar.go"},
|
||||||
|
"Run task_class='task_42' for file 'src/bar.go'.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"auto_apply",
|
||||||
|
"data/_kb/auto_apply.jsonl",
|
||||||
|
"any",
|
||||||
|
[]string{"src/baz.ts"},
|
||||||
|
"Auto-apply: emit a 6-line surgical patch for 'src/baz.ts'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"audits with phase: prefix stripped",
|
||||||
|
"data/_kb/audits.jsonl",
|
||||||
|
"phase:G2",
|
||||||
|
nil,
|
||||||
|
"Audit phase 'G2' and report findings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"observer_reviews",
|
||||||
|
"data/_kb/observer_reviews.jsonl",
|
||||||
|
"any",
|
||||||
|
[]string{"f.rs"},
|
||||||
|
"Observer-review the latest attempt on 'f.rs'.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"contract_analyses with permit: prefix",
|
||||||
|
"data/_kb/contract_analyses.jsonl",
|
||||||
|
"permit:ABC123",
|
||||||
|
nil,
|
||||||
|
"Analyze permit 'ABC123'. Recommend with risk markers.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"outcomes",
|
||||||
|
"data/_kb/outcomes.jsonl",
|
||||||
|
"any",
|
||||||
|
nil,
|
||||||
|
"Run scenario; report per-event outcome with citations.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unknown source falls back to default",
|
||||||
|
"data/_kb/something_new.jsonl",
|
||||||
|
"any",
|
||||||
|
nil,
|
||||||
|
"Source 'something_new' run; produce the appropriate output",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
scored := ScoredRun{
|
||||||
|
EvidenceRunID: "rid",
|
||||||
|
Category: CategoryAccepted,
|
||||||
|
Provenance: Provenance{
|
||||||
|
SourceFile: c.sourceFile,
|
||||||
|
SigHash: "abc",
|
||||||
|
RecordedAt: "2026-04-30T00:00:00Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ev := EvidenceRecord{
|
||||||
|
RunID: "rid",
|
||||||
|
TaskID: c.taskID,
|
||||||
|
ModelRole: RoleExecutor,
|
||||||
|
Text: "model response text",
|
||||||
|
SourceFiles: c.sourceFiles,
|
||||||
|
Provenance: Provenance{SourceFile: c.sourceFile, SigHash: "abc", RecordedAt: "2026-04-30T00:00:00Z"},
|
||||||
|
}
|
||||||
|
sample := SynthesizeSft(scored, ev, "2026-04-30T00:00:00Z", "test-id")
|
||||||
|
if sample == nil {
|
||||||
|
t.Fatalf("expected non-nil sample for %s", c.name)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(sample.Instruction, c.wantPrefix) {
|
||||||
|
t.Errorf("instruction prefix mismatch:\n got: %q\n want: %q...", sample.Instruction, c.wantPrefix)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSynthesizeSft_RejectsExtraction: extraction-class records
|
||||||
|
// have no instruction→response shape (they're pure data extraction,
|
||||||
|
// not model-output-as-training-target). Synthesis must return nil.
|
||||||
|
func TestSynthesizeSft_RejectsExtraction(t *testing.T) {
|
||||||
|
ev := EvidenceRecord{
|
||||||
|
RunID: "rid",
|
||||||
|
ModelRole: RoleExtractor,
|
||||||
|
Text: "extracted data",
|
||||||
|
Provenance: Provenance{SourceFile: "data/_kb/anything.jsonl", SigHash: "abc", RecordedAt: "2026-04-30T00:00:00Z"},
|
||||||
|
}
|
||||||
|
scored := ScoredRun{EvidenceRunID: "rid", Category: CategoryAccepted, Provenance: ev.Provenance}
|
||||||
|
if sample := SynthesizeSft(scored, ev, "2026-04-30T00:00:00Z", "id"); sample != nil {
|
||||||
|
t.Errorf("extraction record must produce nil sample, got %+v", sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSynthesizeSft_RejectsEmptyText: text is the response side of
|
||||||
|
// the SFT pair; empty text means nothing to learn.
|
||||||
|
func TestSynthesizeSft_RejectsEmptyText(t *testing.T) {
|
||||||
|
ev := EvidenceRecord{
|
||||||
|
RunID: "rid",
|
||||||
|
ModelRole: RoleExecutor,
|
||||||
|
Text: " \n\t",
|
||||||
|
Provenance: Provenance{SourceFile: "data/_kb/scrum_reviews.jsonl", SigHash: "abc", RecordedAt: "2026-04-30T00:00:00Z"},
|
||||||
|
}
|
||||||
|
scored := ScoredRun{EvidenceRunID: "rid", Category: CategoryAccepted, Provenance: ev.Provenance}
|
||||||
|
if sample := SynthesizeSft(scored, ev, "2026-04-30T00:00:00Z", "id"); sample != nil {
|
||||||
|
t.Errorf("empty-text record must produce nil sample, got %+v", sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSynthesizeSft_ContextAssembly verifies the terse " · "-joined
|
||||||
|
// context string carries matrix corpora + pathway fingerprints +
|
||||||
|
// model name in the documented order.
|
||||||
|
func TestSynthesizeSft_ContextAssembly(t *testing.T) {
|
||||||
|
ev := EvidenceRecord{
|
||||||
|
RunID: "rid",
|
||||||
|
ModelRole: RoleReviewer,
|
||||||
|
Text: "verdict",
|
||||||
|
ModelName: "qwen3.5",
|
||||||
|
RetrievedContext: &RetrievedContext{
|
||||||
|
MatrixCorpora: []string{"workers", "candidates"},
|
||||||
|
PathwayFingerprintsSeen: 88,
|
||||||
|
},
|
||||||
|
Provenance: Provenance{SourceFile: "data/_kb/scrum_reviews.jsonl", SigHash: "abc", RecordedAt: "2026-04-30T00:00:00Z"},
|
||||||
|
}
|
||||||
|
scored := ScoredRun{EvidenceRunID: "rid", Category: CategoryAccepted, Provenance: ev.Provenance}
|
||||||
|
sample := SynthesizeSft(scored, ev, "2026-04-30T00:00:00Z", "id")
|
||||||
|
if sample == nil {
|
||||||
|
t.Fatalf("expected non-nil sample")
|
||||||
|
}
|
||||||
|
want := "matrix=workers,candidates · pathway_fingerprints=88 · model=qwen3.5"
|
||||||
|
if sample.Context != want {
|
||||||
|
t.Errorf("context mismatch:\n got: %q\n want: %q", sample.Context, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExportSft_FullPort_WritesJSONL covers the fully-ported path:
|
||||||
|
// scored runs + paired evidence both present, synthesis produces
|
||||||
|
// SftSamples, output JSONL is written. Locks the end-to-end
|
||||||
|
// contract that next-wave changes (synthesis tweaks, output layout)
|
||||||
|
// have to preserve.
|
||||||
|
func TestExportSft_FullPort_WritesJSONL(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
scoredDir := filepath.Join(tmp, "data", "scored-runs", "2026", "04", "30")
|
||||||
|
evidenceDir := filepath.Join(tmp, "data", "evidence", "2026", "04", "30")
|
||||||
|
for _, d := range []string{scoredDir, evidenceDir} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scoredJSONL := `{"category":"accepted","evidence_run_id":"r1","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"h1","recorded_at":"2026-04-30T00:00:00Z"}}
|
||||||
|
{"category":"rejected","evidence_run_id":"r2","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"h2","recorded_at":"2026-04-30T00:00:00Z"}}
|
||||||
|
`
|
||||||
|
evidenceJSONL := `{"run_id":"r1","model_role":"executor","text":"some review output","source_files":["src/foo.rs"],"provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"h1","recorded_at":"2026-04-30T00:00:00Z"}}
|
||||||
|
{"run_id":"r2","model_role":"executor","text":"another output","source_files":["src/bar.rs"],"provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"h2","recorded_at":"2026-04-30T00:00:00Z"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(scoredDir, "run.jsonl"), []byte(scoredJSONL), 0o644); err != nil {
|
||||||
|
t.Fatalf("write scored: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(evidenceDir, "run.jsonl"), []byte(evidenceJSONL), 0o644); err != nil {
|
||||||
|
t.Fatalf("write evidence: %v", err)
|
||||||
|
}
|
||||||
|
res, err := ExportSft(ExportSftOptions{
|
||||||
|
Root: tmp,
|
||||||
|
RecordedAt: "2026-04-30T00:00:00Z",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportSft: %v", err)
|
||||||
|
}
|
||||||
|
if res.RecordsRead != 2 || res.RecordsExported != 1 || res.RecordsQuarantined != 1 {
|
||||||
|
t.Errorf("counts: read=%d exported=%d quarantined=%d (want 2/1/1)",
|
||||||
|
res.RecordsRead, res.RecordsExported, res.RecordsQuarantined)
|
||||||
|
}
|
||||||
|
out, err := os.ReadFile(res.OutputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read output: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "Review the file 'src/foo.rs'") {
|
||||||
|
t.Errorf("output missing expected scrum_reviews instruction; got:\n%s", string(out))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(out), "src/bar.rs") {
|
||||||
|
t.Errorf("output contains rejected record's source_file — firewall leak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExportSft_FirewallFiresBeforeEvidenceLoad locks the order-of-
|
||||||
|
// operations: even if evidence records are missing, the
|
||||||
|
// firewall counts records as quarantined so the contamination
|
||||||
|
// guarantee never depends on side data being present. Records
|
||||||
|
// that pass the firewall but lack evidence get the more honest
|
||||||
|
// "not instructable" label rather than being silently exported.
|
||||||
|
func TestExportSft_FirewallFiresBeforeEvidenceLoad(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
dir := filepath.Join(tmp, "data", "scored-runs", "2026", "04", "30")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
jsonl := `{"category":"accepted","evidence_run_id":"r1","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"h1","recorded_at":"2026-04-30T00:00:00Z"}}
|
||||||
|
{"category":"rejected","evidence_run_id":"r2","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"h2","recorded_at":"2026-04-30T00:00:00Z"}}
|
||||||
|
{"category":"partially_accepted","evidence_run_id":"r3","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"h3","recorded_at":"2026-04-30T00:00:00Z"}}
|
||||||
|
{"category":"needs_human_review","evidence_run_id":"r4","provenance":{"source_file":"data/_kb/scrum_reviews.jsonl","sig_hash":"h4","recorded_at":"2026-04-30T00:00:00Z"}}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "run.jsonl"), []byte(jsonl), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
// No evidence directory created → records that pass the firewall
|
||||||
|
// land in "not instructable" since synthesis can't proceed.
|
||||||
|
res, err := ExportSft(ExportSftOptions{
|
||||||
|
Root: tmp,
|
||||||
|
RecordedAt: "2026-04-30T00:00:00Z",
|
||||||
|
DryRun: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportSft: %v", err)
|
||||||
|
}
|
||||||
|
if res.RecordsRead != 4 {
|
||||||
|
t.Errorf("RecordsRead: got %d, want 4", res.RecordsRead)
|
||||||
|
}
|
||||||
|
if res.RecordsQuarantined != 2 {
|
||||||
|
t.Errorf("RecordsQuarantined (firewall-blocked): got %d, want 2", res.RecordsQuarantined)
|
||||||
|
}
|
||||||
|
if res.RecordsExported != 0 {
|
||||||
|
t.Errorf("RecordsExported with no evidence: got %d, want 0", res.RecordsExported)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.QuarantineSummary, "not-instructable=2") {
|
||||||
|
t.Errorf("expected quarantine summary to flag 2 not-instructable, got %q", res.QuarantineSummary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
// Package distillation is the Go port of the Rust v1.0.0 distillation
|
||||||
|
// substrate (frozen at e7636f2). Per ADR-001 #4: port LOGIC, not
|
||||||
|
// bit-identical reproducibility.
|
||||||
|
//
|
||||||
|
// What this package owns (this commit):
|
||||||
|
// - The deterministic scorer: EvidenceRecord → ScoredRun
|
||||||
|
// - Score categories + scorer version constant
|
||||||
|
// - SftSample type + validator with the contamination firewall
|
||||||
|
// (the safety-critical piece — rejected/needs_human_review must
|
||||||
|
// NEVER ship to SFT)
|
||||||
|
//
|
||||||
|
// What's deferred to follow-up commits:
|
||||||
|
// - Materialization layer (file iteration, jsonl read/write,
|
||||||
|
// date-partitioned storage) — operational tooling on top of
|
||||||
|
// the scorer logic
|
||||||
|
// - export_preference, export_rag (other export shapes)
|
||||||
|
// - acceptance harness (the gate that locks v1.0.0)
|
||||||
|
// - replay, receipts, evidence-index builders
|
||||||
|
//
|
||||||
|
// The scorer + SftSample validator are the LOAD-BEARING pieces
|
||||||
|
// per project_distillation_substrate.md memory. The rest is plumbing
|
||||||
|
// that can land incrementally without changing the logic the
|
||||||
|
// downstream learning loop depends on.
|
||||||
|
|
||||||
|
package distillation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScoreCategory is one of the 4 deterministic verdicts. Matches Rust
|
||||||
|
// SCORE_CATEGORIES exactly.
|
||||||
|
type ScoreCategory string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CategoryAccepted ScoreCategory = "accepted"
|
||||||
|
CategoryPartiallyAccepted ScoreCategory = "partially_accepted"
|
||||||
|
CategoryRejected ScoreCategory = "rejected"
|
||||||
|
CategoryNeedsHumanReview ScoreCategory = "needs_human_review"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllScoreCategories lists every legal category — used by validators.
|
||||||
|
var AllScoreCategories = []ScoreCategory{
|
||||||
|
CategoryAccepted,
|
||||||
|
CategoryPartiallyAccepted,
|
||||||
|
CategoryRejected,
|
||||||
|
CategoryNeedsHumanReview,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScorerVersion is hardcoded — the deterministic-output contract
|
||||||
|
// requires this. Bump the literal in the same commit as any scoring-
|
||||||
|
// rule change so the version stamp moves atomically with logic.
|
||||||
|
// Mirrors the Rust SCORER_VERSION (also v1.0.0 at e7636f2).
|
||||||
|
const ScorerVersion = "v1.0.0"
|
||||||
|
|
||||||
|
// SftQualityScore enumerates the categories LEGAL in SFT exports.
|
||||||
|
// SFT_NEVER (defined below) is the inverse — categories that NEVER
|
||||||
|
// ship to SFT under any flag combination. The contamination firewall
|
||||||
|
// is enforced at the schema layer (ValidateSftSample) AND by the
|
||||||
|
// exporter; defense in depth.
|
||||||
|
type SftQualityScore string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SftQualityAccepted SftQualityScore = "accepted"
|
||||||
|
SftQualityPartiallyAccepted SftQualityScore = "partially_accepted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SftQualityScores lists quality scores legal in SFT samples.
|
||||||
|
// Default is SftQualityAccepted only; --include-partial CLI flag
|
||||||
|
// expands to both. rejected and needs_human_review are NEVER legal.
|
||||||
|
var SftQualityScores = []SftQualityScore{
|
||||||
|
SftQualityAccepted,
|
||||||
|
SftQualityPartiallyAccepted,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftNever is the contamination firewall: ScoreCategories that NEVER
|
||||||
|
// ship to SFT under ANY caller flag. Enforced at the schema layer
|
||||||
|
// (ValidateSftSample) AND at the exporter layer. Per the Rust
|
||||||
|
// e7636f2 spec: "Hard non-negotiable: this set never expands. If you
|
||||||
|
// find yourself adding 'needs_human_review' or 'rejected' here, stop
|
||||||
|
// — that's the contamination the spec forbids."
|
||||||
|
//
|
||||||
|
// Exported so callers AND the validator share the same source of
|
||||||
|
// truth. Modifying this constant changes the contract; reviewers
|
||||||
|
// should treat any commit that touches it as a security review.
|
||||||
|
var SftNever = []ScoreCategory{
|
||||||
|
CategoryRejected,
|
||||||
|
CategoryNeedsHumanReview,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftSampleSchemaVersion bumps when the on-wire SftSample shape
|
||||||
|
// changes incompatibly. Match the Rust SFT_SAMPLE_SCHEMA_VERSION.
|
||||||
|
const SftSampleSchemaVersion = 1
|
||||||
|
|
||||||
|
// ScoredRunSchemaVersion bumps when the on-wire ScoredRun shape
|
||||||
|
// changes incompatibly. Match the Rust SCORED_RUN_SCHEMA_VERSION.
|
||||||
|
const ScoredRunSchemaVersion = 1
|
||||||
|
|
||||||
|
// EvidenceSchemaVersion mirrors the Rust EVIDENCE_SCHEMA_VERSION.
|
||||||
|
// This package consumes EvidenceRecord; producing it is a separate
|
||||||
|
// concern (the materialization layer not yet ported).
|
||||||
|
const EvidenceSchemaVersion = 1
|
||||||
|
|
||||||
|
// ModelRole categorizes the kind of model output represented by an
|
||||||
|
// EvidenceRecord. Used by the SFT exporter to filter "real model
|
||||||
|
// output" from pure-extraction rows.
|
||||||
|
type ModelRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleExecutor ModelRole = "executor"
|
||||||
|
RoleReviewer ModelRole = "reviewer"
|
||||||
|
RoleExtractor ModelRole = "extractor"
|
||||||
|
RoleVerifier ModelRole = "verifier"
|
||||||
|
RoleCategorizer ModelRole = "categorizer"
|
||||||
|
RoleTiebreaker ModelRole = "tiebreaker"
|
||||||
|
RoleApplier ModelRole = "applier"
|
||||||
|
RoleEmbedder ModelRole = "embedder"
|
||||||
|
RoleOther ModelRole = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provenance is the source-linkage every distillation record carries.
|
||||||
|
// SourceFile is required (no record without source linkage); other
|
||||||
|
// fields are best-effort for de-duplication and trace-back.
|
||||||
|
//
|
||||||
|
// LineOffset is intentionally NOT `omitempty`: line offset 0 is the
|
||||||
|
// first row of a source file — a real, semantically-meaningful value.
|
||||||
|
// Stripping it on first-row records would silently diverge from the
|
||||||
|
// Bun materializer (which always emits the field). Surfaced by the
|
||||||
|
// 2026-05-02 cross-runtime parity probe.
|
||||||
|
type Provenance struct {
|
||||||
|
SourceFile string `json:"source_file"`
|
||||||
|
LineOffset int64 `json:"line_offset"`
|
||||||
|
SigHash string `json:"sig_hash"`
|
||||||
|
RecordedAt string `json:"recorded_at"` // ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserverVerdict is what an observer returned for an executor's
|
||||||
|
// output. Matches the Rust enum but as a string type for JSON
|
||||||
|
// flexibility.
|
||||||
|
type ObserverVerdict string
|
||||||
|
|
||||||
|
const (
|
||||||
|
VerdictAccept ObserverVerdict = "accept"
|
||||||
|
VerdictReject ObserverVerdict = "reject"
|
||||||
|
VerdictCycle ObserverVerdict = "cycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EvidenceRecord is one row in the canonical evidence stream.
|
||||||
|
// Producing it (transforms from raw KB streams) is a separate
|
||||||
|
// concern; this package consumes it.
|
||||||
|
//
|
||||||
|
// Fields mirror the Rust EvidenceRecord at e7636f2. Optional fields
|
||||||
|
// use Go pointers / slices so missing-vs-empty stays distinguishable
|
||||||
|
// for the scorer's heuristics.
|
||||||
|
type EvidenceRecord struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
|
||||||
|
Provenance Provenance `json:"provenance"`
|
||||||
|
|
||||||
|
ModelName string `json:"model_name,omitempty"`
|
||||||
|
ModelProvider string `json:"model_provider,omitempty"`
|
||||||
|
ModelRole ModelRole `json:"model_role,omitempty"`
|
||||||
|
|
||||||
|
InputHash string `json:"input_hash,omitempty"`
|
||||||
|
OutputHash string `json:"output_hash,omitempty"`
|
||||||
|
|
||||||
|
SourceFiles []string `json:"source_files,omitempty"`
|
||||||
|
CommandsRun []string `json:"commands_run,omitempty"`
|
||||||
|
|
||||||
|
RetrievedContext *RetrievedContext `json:"retrieved_context,omitempty"`
|
||||||
|
|
||||||
|
ObserverNotes []string `json:"observer_notes,omitempty"`
|
||||||
|
ObserverVerdict ObserverVerdict `json:"observer_verdict,omitempty"`
|
||||||
|
ObserverConfidence float64 `json:"observer_confidence,omitempty"`
|
||||||
|
ScratchpadSummary string `json:"scratchpad_summary,omitempty"`
|
||||||
|
|
||||||
|
SuccessMarkers []string `json:"success_markers,omitempty"`
|
||||||
|
FailureMarkers []string `json:"failure_markers,omitempty"`
|
||||||
|
|
||||||
|
ValidationResults map[string]any `json:"validation_results,omitempty"`
|
||||||
|
|
||||||
|
HumanOverride *HumanOverride `json:"human_override,omitempty"`
|
||||||
|
|
||||||
|
CostUSD float64 `json:"cost_usd,omitempty"`
|
||||||
|
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||||
|
PromptTokens int64 `json:"prompt_tokens,omitempty"`
|
||||||
|
CompletionTokens int64 `json:"completion_tokens,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
|
||||||
|
// Domain-specific bucket for source-row fields that don't earn a
|
||||||
|
// top-level slot. e.g. contract_analyses carries `contractor` here.
|
||||||
|
// Typed scalar values only — keep this small or it becomes a junk
|
||||||
|
// drawer. Mirrors EvidenceRecord.metadata in evidence_record.ts.
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetrievedContext captures what the model saw via retrieval. Matches
|
||||||
|
// the Rust shape exactly so the JSON round-trips byte-identical (per
|
||||||
|
// ADR-001 #4 "logic, not bit-identical" — but on-wire compatibility
|
||||||
|
// is desirable for tooling that consumes EvidenceRecord JSONL).
|
||||||
|
type RetrievedContext struct {
|
||||||
|
MatrixCorpora []string `json:"matrix_corpora,omitempty"`
|
||||||
|
MatrixHits int `json:"matrix_hits,omitempty"`
|
||||||
|
MatrixChunksKept int `json:"matrix_chunks_kept,omitempty"`
|
||||||
|
MatrixChunksDropped int `json:"matrix_chunks_dropped,omitempty"`
|
||||||
|
PathwayFingerprintsSeen int `json:"pathway_fingerprints_seen,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HumanOverride captures a human-in-the-loop decision overriding the
|
||||||
|
// scorer's verdict. Recorded but doesn't change the scorer's output;
|
||||||
|
// downstream consumers (UI, distillation acceptance) decide how to
|
||||||
|
// treat it.
|
||||||
|
type HumanOverride struct {
|
||||||
|
Overrider string `json:"overrider"`
|
||||||
|
Decision string `json:"decision"` // accept|reject|needs_review
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
OverriddenAt string `json:"overridden_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubScores carries the deterministic scorer's intermediate signals
|
||||||
|
// alongside the final ScoreCategory. Persisted on every ScoredRun
|
||||||
|
// so a downstream UI can show "why" without re-running the scorer.
|
||||||
|
type SubScores struct {
|
||||||
|
CargoGreen *bool `json:"cargo_green,omitempty"`
|
||||||
|
AnchorGrounding *float64 `json:"anchor_grounding,omitempty"`
|
||||||
|
SchemaValid *bool `json:"schema_valid,omitempty"`
|
||||||
|
PathwayReplaySucceeded *bool `json:"pathway_replay_succeeded,omitempty"`
|
||||||
|
ObserverVerdict ObserverVerdict `json:"observer_verdict,omitempty"`
|
||||||
|
AcceptedOnAttempt *int `json:"accepted_on_attempt,omitempty"`
|
||||||
|
// Extra fields the Rust schema accepted as `[key: string]: unknown`.
|
||||||
|
// Captured here as a free-form map so future signals don't require
|
||||||
|
// type-system changes.
|
||||||
|
Extras map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScoredRun is the deterministic scorer's output. One per
|
||||||
|
// EvidenceRecord. Provenance ties back to the materialized evidence
|
||||||
|
// row (not the raw source stream).
|
||||||
|
type ScoredRun struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
EvidenceRunID string `json:"evidence_run_id"`
|
||||||
|
EvidenceTaskID string `json:"evidence_task_id"`
|
||||||
|
Category ScoreCategory `json:"category"`
|
||||||
|
Reasons []string `json:"reasons"` // non-empty
|
||||||
|
ScoredAt string `json:"scored_at"`
|
||||||
|
ScorerVersion string `json:"scorer_version"`
|
||||||
|
SubScores *SubScores `json:"sub_scores,omitempty"`
|
||||||
|
Provenance Provenance `json:"provenance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftSample is one entry in exports/sft/instruction_response.jsonl.
|
||||||
|
// The contamination firewall lives in ValidateSftSample.
|
||||||
|
type SftSample struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Instruction string `json:"instruction"`
|
||||||
|
Context string `json:"context"` // empty allowed; null/missing not
|
||||||
|
Response string `json:"response"`
|
||||||
|
SourceRunID string `json:"source_run_id"`
|
||||||
|
QualityScore SftQualityScore `json:"quality_score"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Provenance Provenance `json:"provenance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Validators ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ValidationError is a single field-level violation.
|
||||||
|
type ValidationError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationErrors is the joinable error returned by the validators
|
||||||
|
// when one or more fields violate the schema.
|
||||||
|
type ValidationErrors []ValidationError
|
||||||
|
|
||||||
|
func (es ValidationErrors) Error() string {
|
||||||
|
if len(es) == 0 {
|
||||||
|
return "no errors"
|
||||||
|
}
|
||||||
|
parts := make([]string, len(es))
|
||||||
|
for i, e := range es {
|
||||||
|
parts[i] = e.Error()
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasErrors returns true when one or more errors are present.
|
||||||
|
func (es ValidationErrors) HasErrors() bool { return len(es) > 0 }
|
||||||
|
|
||||||
|
// ValidateScoredRun mirrors the Rust validateScoredRun. Returns nil
|
||||||
|
// on success or a ValidationErrors with the field-level violations.
|
||||||
|
func ValidateScoredRun(r ScoredRun) error {
|
||||||
|
var errs ValidationErrors
|
||||||
|
if r.SchemaVersion != ScoredRunSchemaVersion {
|
||||||
|
errs = append(errs, ValidationError{
|
||||||
|
"schema_version",
|
||||||
|
fmt.Sprintf("expected %d, got %d", ScoredRunSchemaVersion, r.SchemaVersion),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if r.EvidenceRunID == "" {
|
||||||
|
errs = append(errs, ValidationError{"evidence_run_id", "must be non-empty"})
|
||||||
|
}
|
||||||
|
if r.EvidenceTaskID == "" {
|
||||||
|
errs = append(errs, ValidationError{"evidence_task_id", "must be non-empty"})
|
||||||
|
}
|
||||||
|
if !validISOTimestamp(r.ScoredAt) {
|
||||||
|
errs = append(errs, ValidationError{"scored_at", "must be ISO 8601 timestamp"})
|
||||||
|
}
|
||||||
|
if r.ScorerVersion == "" {
|
||||||
|
errs = append(errs, ValidationError{"scorer_version", "must be non-empty"})
|
||||||
|
}
|
||||||
|
if len(r.Reasons) == 0 {
|
||||||
|
errs = append(errs, ValidationError{"reasons", "must be non-empty (every score needs a reason)"})
|
||||||
|
}
|
||||||
|
if !isValidCategory(r.Category) {
|
||||||
|
errs = append(errs, ValidationError{"category", fmt.Sprintf("must be one of %v, got %q", AllScoreCategories, r.Category)})
|
||||||
|
}
|
||||||
|
if err := validateProvenance(r.Provenance, "provenance"); err != nil {
|
||||||
|
errs = append(errs, err...)
|
||||||
|
}
|
||||||
|
if r.SubScores != nil && r.SubScores.AnchorGrounding != nil {
|
||||||
|
ag := *r.SubScores.AnchorGrounding
|
||||||
|
if ag < 0 || ag > 1 {
|
||||||
|
errs = append(errs, ValidationError{"sub_scores.anchor_grounding", "must be in [0, 1]"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errs.HasErrors() {
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSftSample is the contamination firewall. Returns ErrSftContamination
|
||||||
|
// (wrapped) when quality_score is in SftNever — which is the safety-critical
|
||||||
|
// guarantee the spec calls non-negotiable.
|
||||||
|
//
|
||||||
|
// Other field violations come back as ValidationErrors.
|
||||||
|
func ValidateSftSample(s SftSample) error {
|
||||||
|
var errs ValidationErrors
|
||||||
|
if s.SchemaVersion != SftSampleSchemaVersion {
|
||||||
|
errs = append(errs, ValidationError{
|
||||||
|
"schema_version",
|
||||||
|
fmt.Sprintf("expected %d, got %d", SftSampleSchemaVersion, s.SchemaVersion),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if s.ID == "" {
|
||||||
|
errs = append(errs, ValidationError{"id", "must be non-empty"})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.Instruction) == "" {
|
||||||
|
errs = append(errs, ValidationError{"instruction", "must be non-whitespace (no empty pairs)"})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.Response) == "" {
|
||||||
|
errs = append(errs, ValidationError{"response", "must be non-whitespace (no empty pairs)"})
|
||||||
|
}
|
||||||
|
// Context is required-string but empty is allowed.
|
||||||
|
// (Field is always typed as string in Go, so the only way to
|
||||||
|
// distinguish "set" from "missing" was via the JSON layer; here
|
||||||
|
// empty is fine.)
|
||||||
|
if s.SourceRunID == "" {
|
||||||
|
errs = append(errs, ValidationError{"source_run_id", "must be non-empty"})
|
||||||
|
}
|
||||||
|
if !validISOTimestamp(s.CreatedAt) {
|
||||||
|
errs = append(errs, ValidationError{"created_at", "must be ISO 8601 timestamp"})
|
||||||
|
}
|
||||||
|
if err := validateProvenance(s.Provenance, "provenance"); err != nil {
|
||||||
|
errs = append(errs, err...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contamination firewall. Hard non-negotiable per the spec.
|
||||||
|
if !isLegalSftQualityScore(s.QualityScore) {
|
||||||
|
// If it's in SftNever, surface the firewall sentinel — callers
|
||||||
|
// can errors.Is(err, ErrSftContamination) to reliably detect
|
||||||
|
// "the spec said never" as opposed to "you typo'd a category."
|
||||||
|
if isContaminationCategory(s.QualityScore) {
|
||||||
|
return fmt.Errorf("%w: quality_score %q in SftNever (rejected/needs_human_review never legal in SFT)",
|
||||||
|
ErrSftContamination, s.QualityScore)
|
||||||
|
}
|
||||||
|
errs = append(errs, ValidationError{
|
||||||
|
"quality_score",
|
||||||
|
fmt.Sprintf("must be one of %v, got %q", SftQualityScores, s.QualityScore),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs.HasErrors() {
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrSftContamination is the firewall sentinel — when ValidateSftSample
|
||||||
|
// rejects a sample because its quality_score is in SftNever, callers
|
||||||
|
// can errors.Is(err, ErrSftContamination) to reliably distinguish
|
||||||
|
// "spec violation" from "typo'd category."
|
||||||
|
var ErrSftContamination = errors.New("distillation: SFT contamination — quality_score in SftNever")
|
||||||
|
|
||||||
|
// ─── Internal helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func isValidCategory(c ScoreCategory) bool {
|
||||||
|
for _, v := range AllScoreCategories {
|
||||||
|
if c == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLegalSftQualityScore(q SftQualityScore) bool {
|
||||||
|
for _, v := range SftQualityScores {
|
||||||
|
if q == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isContaminationCategory(q SftQualityScore) bool {
|
||||||
|
// Compare as ScoreCategory — the on-wire string is the same; this
|
||||||
|
// just guards the firewall against typos that happen to match
|
||||||
|
// SftNever string-wise.
|
||||||
|
for _, v := range SftNever {
|
||||||
|
if string(v) == string(q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validISOTimestamp(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// time.Parse with RFC3339 covers most ISO 8601. We accept both
|
||||||
|
// the basic and nano variants since the Rust producers vary.
|
||||||
|
if _, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProvenance(p Provenance, field string) ValidationErrors {
|
||||||
|
var errs ValidationErrors
|
||||||
|
if p.SourceFile == "" {
|
||||||
|
errs = append(errs, ValidationError{field + ".source_file", "must be non-empty"})
|
||||||
|
}
|
||||||
|
if p.SigHash == "" {
|
||||||
|
errs = append(errs, ValidationError{field + ".sig_hash", "must be non-empty"})
|
||||||
|
}
|
||||||
|
if !validISOTimestamp(p.RecordedAt) {
|
||||||
|
errs = append(errs, ValidationError{field + ".recorded_at", "must be ISO 8601 timestamp"})
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalSubScores is a shim — Go's encoding/json doesn't merge a
|
||||||
|
// "rest" map into the struct's JSON output by default. Callers that
|
||||||
|
// need Extras serialized into the same object can use this helper.
|
||||||
|
func MarshalSubScores(s *SubScores) ([]byte, error) {
|
||||||
|
if s == nil {
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
// First marshal the typed fields normally.
|
||||||
|
type alias SubScores
|
||||||
|
base, err := json.Marshal((*alias)(s))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(s.Extras) == 0 {
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
// Decode back to a map, merge Extras, re-encode. Less efficient
|
||||||
|
// but keeps the field semantics correct (typed fields override
|
||||||
|
// extras on collision — first-write-wins for known keys).
|
||||||
|
var combined map[string]any
|
||||||
|
if err := json.Unmarshal(base, &combined); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k, v := range s.Extras {
|
||||||
|
if _, exists := combined[k]; !exists {
|
||||||
|
combined[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.Marshal(combined)
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
// Package drift quantifies when historical decisions stop matching
|
||||||
|
// current reality. Per the PRD's 5-loop substrate, this is loop 5
|
||||||
|
// (drift) — distinct from the rating+distillation loop because
|
||||||
|
// drift is about MEASUREMENT, not learning. The learning loop says
|
||||||
|
// "this match worked, remember it"; the drift loop says "the
|
||||||
|
// playbook entry from 4 months ago — does it still match what the
|
||||||
|
// substrate would surface today?"
|
||||||
|
//
|
||||||
|
// First-shipped drift shape: SCORER drift. When the deterministic
|
||||||
|
// scorer's logic changes (ScorerVersion bumped), historical
|
||||||
|
// ScoredRuns may no longer match what the current scorer would
|
||||||
|
// produce on the same EvidenceRecord. ComputeScorerDrift re-runs
|
||||||
|
// the current scorer over a slice of (EvidenceRecord, persisted
|
||||||
|
// category) pairs and reports mismatches.
|
||||||
|
//
|
||||||
|
// Why this matters: the rating+distillation loop only learns
|
||||||
|
// forward. Without a drift quantifier, a scorer-rule change
|
||||||
|
// silently invalidates the historical training data feeding the
|
||||||
|
// loop. With drift quantification, a rule change surfaces a
|
||||||
|
// concrete number ("847 of 4701 historical scoredruns now
|
||||||
|
// disagree") that triggers a re-score-and-retrain cycle rather
|
||||||
|
// than letting the substrate quietly rot.
|
||||||
|
//
|
||||||
|
// Future drift shapes (not in this commit):
|
||||||
|
// - PLAYBOOK drift: for each playbook entry, re-run its query
|
||||||
|
// through current matrix-search; if the recorded answer is no
|
||||||
|
// longer in top-K, the world has moved.
|
||||||
|
// - EMBEDDING drift: KS-test on the distribution of embedding
|
||||||
|
// vectors at T1 vs T2; large shifts = the corpus has changed
|
||||||
|
// materially.
|
||||||
|
// - AUDIT BASELINE drift: track how PR audit verdicts shift over
|
||||||
|
// scorer/auditor versions; matches the Rust audit_baselines.jsonl
|
||||||
|
// longitudinal signal.
|
||||||
|
|
||||||
|
package drift
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/distillation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScorerDriftEntry is one mismatch — a historical (record, category)
|
||||||
|
// pair where the current scorer disagrees with the persisted
|
||||||
|
// verdict. Reasons captures the current scorer's explanation so
|
||||||
|
// operators can see WHY the verdict changed.
|
||||||
|
type ScorerDriftEntry struct {
|
||||||
|
EvidenceRunID string `json:"evidence_run_id"`
|
||||||
|
EvidenceTaskID string `json:"evidence_task_id"`
|
||||||
|
PersistedCategory distillation.ScoreCategory `json:"persisted_category"`
|
||||||
|
CurrentCategory distillation.ScoreCategory `json:"current_category"`
|
||||||
|
CurrentReasons []string `json:"current_reasons"`
|
||||||
|
SourceFile string `json:"source_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryShift is one cell in the drift matrix — "X persisted
|
||||||
|
// records that NOW classify as Y." e.g. "12 records that were
|
||||||
|
// 'rejected' yesterday are 'partially_accepted' today."
|
||||||
|
type CategoryShift struct {
|
||||||
|
From distillation.ScoreCategory `json:"from"`
|
||||||
|
To distillation.ScoreCategory `json:"to"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScorerDriftReport is the summary returned by ComputeScorerDrift.
|
||||||
|
// The shape is intentionally machine-readable so a downstream
|
||||||
|
// dashboard / alerting layer can threshold on Drifted / TotalChecked
|
||||||
|
// without parsing the entries list.
|
||||||
|
type ScorerDriftReport struct {
|
||||||
|
ScorerVersion string `json:"scorer_version"` // current scorer's version
|
||||||
|
TotalChecked int `json:"total_checked"`
|
||||||
|
Matched int `json:"matched"` // current == persisted
|
||||||
|
Drifted int `json:"drifted"` // current != persisted
|
||||||
|
DriftRate float64 `json:"drift_rate"` // Drifted / TotalChecked
|
||||||
|
ShiftMatrix []CategoryShift `json:"shift_matrix,omitempty"`
|
||||||
|
Entries []ScorerDriftEntry `json:"entries,omitempty"` // mismatches only
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScorerDriftInput is one (record, persisted_category) pair to check.
|
||||||
|
// Caller is responsible for materializing these from disk; this
|
||||||
|
// package is pure compute.
|
||||||
|
type ScorerDriftInput struct {
|
||||||
|
Record distillation.EvidenceRecord
|
||||||
|
PersistedCategory distillation.ScoreCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeScorerDrift re-runs distillation.ScoreRecord over each
|
||||||
|
// input and reports mismatches. Pure function — no I/O. The caller
|
||||||
|
// supplies the inputs (typically by reading a directory of
|
||||||
|
// scored-runs JSONL alongside the corresponding evidence JSONL).
|
||||||
|
//
|
||||||
|
// IncludeEntries controls whether the per-mismatch detail list is
|
||||||
|
// populated. For large corpora (e.g. 4,701 fill events) the
|
||||||
|
// summary numbers may be all the caller needs; setting this to
|
||||||
|
// false avoids allocating the entries slice.
|
||||||
|
func ComputeScorerDrift(inputs []ScorerDriftInput, includeEntries bool) ScorerDriftReport {
|
||||||
|
report := ScorerDriftReport{
|
||||||
|
ScorerVersion: distillation.ScorerVersion,
|
||||||
|
TotalChecked: len(inputs),
|
||||||
|
}
|
||||||
|
|
||||||
|
shiftCounts := make(map[[2]distillation.ScoreCategory]int)
|
||||||
|
|
||||||
|
for _, in := range inputs {
|
||||||
|
out := distillation.ScoreRecord(in.Record)
|
||||||
|
if out.Category == in.PersistedCategory {
|
||||||
|
report.Matched++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
report.Drifted++
|
||||||
|
shiftCounts[[2]distillation.ScoreCategory{in.PersistedCategory, out.Category}]++
|
||||||
|
if includeEntries {
|
||||||
|
report.Entries = append(report.Entries, ScorerDriftEntry{
|
||||||
|
EvidenceRunID: in.Record.RunID,
|
||||||
|
EvidenceTaskID: in.Record.TaskID,
|
||||||
|
PersistedCategory: in.PersistedCategory,
|
||||||
|
CurrentCategory: out.Category,
|
||||||
|
CurrentReasons: out.Reasons,
|
||||||
|
SourceFile: in.Record.Provenance.SourceFile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.TotalChecked > 0 {
|
||||||
|
report.DriftRate = float64(report.Drifted) / float64(report.TotalChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(shiftCounts) > 0 {
|
||||||
|
report.ShiftMatrix = make([]CategoryShift, 0, len(shiftCounts))
|
||||||
|
for k, v := range shiftCounts {
|
||||||
|
report.ShiftMatrix = append(report.ShiftMatrix, CategoryShift{
|
||||||
|
From: k[0], To: k[1], Count: v,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Sort: largest shifts first, then alphabetical-ish for ties.
|
||||||
|
// Stable ordering matters for downstream display and JSON
|
||||||
|
// determinism in tests.
|
||||||
|
sort.Slice(report.ShiftMatrix, func(i, j int) bool {
|
||||||
|
a, b := report.ShiftMatrix[i], report.ShiftMatrix[j]
|
||||||
|
if a.Count != b.Count {
|
||||||
|
return a.Count > b.Count
|
||||||
|
}
|
||||||
|
if a.From != b.From {
|
||||||
|
return string(a.From) < string(b.From)
|
||||||
|
}
|
||||||
|
return string(a.To) < string(b.To)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Distribution drift via Population Stability Index (PSI).
|
||||||
|
//
|
||||||
|
// Closes OPEN #3's "concrete distribution-drift signal" — pairs with
|
||||||
|
// ComputeScorerDrift's "categorical drift" to give callers a
|
||||||
|
// continuous-value drift metric for things like cosine distances,
|
||||||
|
// judge ratings, query top-1 distance distributions over time.
|
||||||
|
//
|
||||||
|
// Why PSI: standard in finance/risk for distribution-shift monitoring,
|
||||||
|
// well-defined verdict tiers (< 0.1 stable, 0.1-0.25 minor, > 0.25
|
||||||
|
// major), tolerant of moderate sample sizes, no assumption about
|
||||||
|
// distribution shape. Alternatives (KS-test, KL-divergence) all
|
||||||
|
// have failure modes when one bucket has zero observations; PSI's
|
||||||
|
// epsilon-clamping handles that gracefully.
|
||||||
|
//
|
||||||
|
// Math: PSI = Σᵢ (actualᵢ - expectedᵢ) × ln(actualᵢ / expectedᵢ)
|
||||||
|
// where actualᵢ and expectedᵢ are the proportion of observations
|
||||||
|
// falling into bucket i (zero values are clamped to a small
|
||||||
|
// epsilon so the log doesn't blow up).
|
||||||
|
//
|
||||||
|
// First-shipped use case: comparing the distribution of cosine top-1
|
||||||
|
// distances at T0 vs T1 to detect when the embedder's behavior
|
||||||
|
// against the corpus has shifted materially (e.g. after a model
|
||||||
|
// upgrade or a corpus refresh).
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// DistributionDriftTier is a verdict bucket for the PSI value.
|
||||||
|
// Standard PSI thresholds from finance/risk; documented in the
|
||||||
|
// function-level comment for ComputeDistributionDrift.
|
||||||
|
type DistributionDriftTier string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DriftTierStable DistributionDriftTier = "stable" // PSI < 0.1
|
||||||
|
DriftTierMinor DistributionDriftTier = "minor" // 0.1 ≤ PSI < 0.25
|
||||||
|
DriftTierMajor DistributionDriftTier = "major" // PSI ≥ 0.25
|
||||||
|
)
|
||||||
|
|
||||||
|
// DistributionDriftBucket is one bucket's contribution to the PSI sum.
|
||||||
|
// Useful for drilldown — operators can see WHICH part of the
|
||||||
|
// distribution shifted (e.g. "the [0.3, 0.4) bucket gained 12% of
|
||||||
|
// observations from T0 to T1").
|
||||||
|
type DistributionDriftBucket struct {
|
||||||
|
Lower float64 `json:"lower"`
|
||||||
|
Upper float64 `json:"upper"`
|
||||||
|
BaselineCount int `json:"baseline_count"`
|
||||||
|
CurrentCount int `json:"current_count"`
|
||||||
|
BaselineRatio float64 `json:"baseline_ratio"` // [0, 1]
|
||||||
|
CurrentRatio float64 `json:"current_ratio"` // [0, 1]
|
||||||
|
PSIPart float64 `json:"psi_part"` // contribution to PSI
|
||||||
|
}
|
||||||
|
|
||||||
|
// DistributionDriftReport is the aggregate output of a PSI run.
|
||||||
|
type DistributionDriftReport struct {
|
||||||
|
PSI float64 `json:"psi"`
|
||||||
|
Tier DistributionDriftTier `json:"tier"`
|
||||||
|
Buckets []DistributionDriftBucket `json:"buckets"`
|
||||||
|
BaselineN int `json:"baseline_n"`
|
||||||
|
CurrentN int `json:"current_n"`
|
||||||
|
NumBuckets int `json:"num_buckets"`
|
||||||
|
Min float64 `json:"min"`
|
||||||
|
Max float64 `json:"max"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DistributionDriftInput is the input shape for ComputeDistributionDrift.
|
||||||
|
// Baseline is the reference distribution (T0); Current is the new
|
||||||
|
// distribution (T1). NumBuckets defaults to 10 when zero, capped at
|
||||||
|
// 100 to keep the per-bucket math stable on small samples.
|
||||||
|
type DistributionDriftInput struct {
|
||||||
|
Baseline []float64
|
||||||
|
Current []float64
|
||||||
|
NumBuckets int
|
||||||
|
}
|
||||||
|
|
||||||
|
// driftEpsilon clamps zero-bucket ratios so log doesn't blow up.
|
||||||
|
// 1e-4 is the standard PSI choice — represents "less than 0.01% of
|
||||||
|
// observations." Anything smaller would let one rare-bucket shift
|
||||||
|
// dominate the PSI sum unfairly.
|
||||||
|
const driftEpsilon = 1e-4
|
||||||
|
|
||||||
|
// ComputeDistributionDrift returns a PSI-based drift report between
|
||||||
|
// the baseline and current distributions. Buckets span [min, max] of
|
||||||
|
// the COMBINED data (so neither side falls outside) with
|
||||||
|
// equal-frequency buckets on the baseline (so each baseline bucket
|
||||||
|
// has roughly the same count — robust to skewed distributions like
|
||||||
|
// cosine distances which cluster near 0).
|
||||||
|
//
|
||||||
|
// Empty inputs return PSI=0, tier=stable. Caller should also check
|
||||||
|
// BaselineN/CurrentN before trusting the verdict — PSI on tiny
|
||||||
|
// samples is statistical noise.
|
||||||
|
//
|
||||||
|
// Thresholds (industry-standard, citable):
|
||||||
|
// - PSI < 0.10 → stable: distributions are operationally equivalent
|
||||||
|
// - 0.10 ≤ PSI < 0.25 → minor: investigate but not alarming
|
||||||
|
// - PSI ≥ 0.25 → major: distributions have shifted materially;
|
||||||
|
// downstream decisions trained on baseline may be invalid
|
||||||
|
func ComputeDistributionDrift(in DistributionDriftInput) DistributionDriftReport {
|
||||||
|
report := DistributionDriftReport{
|
||||||
|
BaselineN: len(in.Baseline),
|
||||||
|
CurrentN: len(in.Current),
|
||||||
|
}
|
||||||
|
if report.BaselineN == 0 || report.CurrentN == 0 {
|
||||||
|
report.Tier = DriftTierStable
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
n := in.NumBuckets
|
||||||
|
if n <= 0 {
|
||||||
|
n = 10
|
||||||
|
}
|
||||||
|
if n > 100 {
|
||||||
|
n = 100
|
||||||
|
}
|
||||||
|
report.NumBuckets = n
|
||||||
|
|
||||||
|
// Combined min/max so both distributions fit the bucket range.
|
||||||
|
minV, maxV := in.Baseline[0], in.Baseline[0]
|
||||||
|
for _, v := range in.Baseline {
|
||||||
|
if v < minV {
|
||||||
|
minV = v
|
||||||
|
}
|
||||||
|
if v > maxV {
|
||||||
|
maxV = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range in.Current {
|
||||||
|
if v < minV {
|
||||||
|
minV = v
|
||||||
|
}
|
||||||
|
if v > maxV {
|
||||||
|
maxV = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.Min = minV
|
||||||
|
report.Max = maxV
|
||||||
|
if maxV == minV {
|
||||||
|
// All values identical — trivially stable, no meaningful PSI.
|
||||||
|
report.Tier = DriftTierStable
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal-WIDTH bucketing. Equal-frequency would be more robust on
|
||||||
|
// skewed distributions, but it complicates the math (need to
|
||||||
|
// quantile-sort the baseline) for marginal gain on the symmetric
|
||||||
|
// distributions PSI is most useful for. Operators with skewed
|
||||||
|
// data can pre-bucket their inputs into normalized scores.
|
||||||
|
width := (maxV - minV) / float64(n)
|
||||||
|
bucketIdx := func(v float64) int {
|
||||||
|
if v >= maxV {
|
||||||
|
return n - 1 // right-edge inclusive
|
||||||
|
}
|
||||||
|
idx := int((v - minV) / width)
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
if idx >= n {
|
||||||
|
idx = n - 1
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
bCounts := make([]int, n)
|
||||||
|
cCounts := make([]int, n)
|
||||||
|
for _, v := range in.Baseline {
|
||||||
|
bCounts[bucketIdx(v)]++
|
||||||
|
}
|
||||||
|
for _, v := range in.Current {
|
||||||
|
cCounts[bucketIdx(v)]++
|
||||||
|
}
|
||||||
|
|
||||||
|
report.Buckets = make([]DistributionDriftBucket, 0, n)
|
||||||
|
bN := float64(report.BaselineN)
|
||||||
|
cN := float64(report.CurrentN)
|
||||||
|
psi := 0.0
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
bRatio := float64(bCounts[i]) / bN
|
||||||
|
cRatio := float64(cCounts[i]) / cN
|
||||||
|
// Epsilon clamp so empty buckets don't blow up log.
|
||||||
|
if bRatio < driftEpsilon {
|
||||||
|
bRatio = driftEpsilon
|
||||||
|
}
|
||||||
|
if cRatio < driftEpsilon {
|
||||||
|
cRatio = driftEpsilon
|
||||||
|
}
|
||||||
|
part := (cRatio - bRatio) * math.Log(cRatio/bRatio)
|
||||||
|
psi += part
|
||||||
|
report.Buckets = append(report.Buckets, DistributionDriftBucket{
|
||||||
|
Lower: minV + float64(i)*width,
|
||||||
|
Upper: minV + float64(i+1)*width,
|
||||||
|
BaselineCount: bCounts[i],
|
||||||
|
CurrentCount: cCounts[i],
|
||||||
|
BaselineRatio: float64(bCounts[i]) / bN,
|
||||||
|
CurrentRatio: float64(cCounts[i]) / cN,
|
||||||
|
PSIPart: part,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
report.PSI = psi
|
||||||
|
switch {
|
||||||
|
case psi < 0.10:
|
||||||
|
report.Tier = DriftTierStable
|
||||||
|
case psi < 0.25:
|
||||||
|
report.Tier = DriftTierMinor
|
||||||
|
default:
|
||||||
|
report.Tier = DriftTierMajor
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package drift
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/distillation"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mkInput(sourceFile string, persisted distillation.ScoreCategory, succ []string) ScorerDriftInput {
|
||||||
|
return ScorerDriftInput{
|
||||||
|
Record: distillation.EvidenceRecord{
|
||||||
|
RunID: "run-x",
|
||||||
|
TaskID: "task-x",
|
||||||
|
Timestamp: "2026-01-01T00:00:00Z",
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: distillation.Provenance{
|
||||||
|
SourceFile: sourceFile,
|
||||||
|
SigHash: "abc",
|
||||||
|
RecordedAt: "2026-01-01T00:00:01Z",
|
||||||
|
},
|
||||||
|
SuccessMarkers: succ,
|
||||||
|
},
|
||||||
|
PersistedCategory: persisted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeScorerDrift_NoDrift(t *testing.T) {
|
||||||
|
// All inputs have persisted=accepted matching what the current
|
||||||
|
// scrum_review scorer produces on accepted_on_attempt_1.
|
||||||
|
inputs := []ScorerDriftInput{
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_1"}),
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_1"}),
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_1"}),
|
||||||
|
}
|
||||||
|
r := ComputeScorerDrift(inputs, true)
|
||||||
|
if r.TotalChecked != 3 || r.Matched != 3 || r.Drifted != 0 {
|
||||||
|
t.Errorf("no-drift case: total=%d matched=%d drifted=%d",
|
||||||
|
r.TotalChecked, r.Matched, r.Drifted)
|
||||||
|
}
|
||||||
|
if r.DriftRate != 0 {
|
||||||
|
t.Errorf("drift_rate: want 0, got %v", r.DriftRate)
|
||||||
|
}
|
||||||
|
if len(r.Entries) != 0 {
|
||||||
|
t.Errorf("entries: want 0, got %d", len(r.Entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeScorerDrift_ShiftDetected(t *testing.T) {
|
||||||
|
// Simulate a historical labeling where the persisted scorer
|
||||||
|
// thought attempt-2 acceptances were "accepted" but the current
|
||||||
|
// scorer (this code) categorizes them as "partially_accepted".
|
||||||
|
// Drift should fire on those.
|
||||||
|
inputs := []ScorerDriftInput{
|
||||||
|
// Match: attempt 1 → accepted (still)
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_1"}),
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_1"}),
|
||||||
|
// Drift: persisted thought attempt-2 was accepted, today's scorer says partial
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_2"}),
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_3"}),
|
||||||
|
// Drift: persisted thought attempt-5 was accepted, today's scorer says partial (high-cost)
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_5"}),
|
||||||
|
}
|
||||||
|
r := ComputeScorerDrift(inputs, true)
|
||||||
|
if r.TotalChecked != 5 {
|
||||||
|
t.Errorf("total: want 5, got %d", r.TotalChecked)
|
||||||
|
}
|
||||||
|
if r.Matched != 2 {
|
||||||
|
t.Errorf("matched: want 2, got %d", r.Matched)
|
||||||
|
}
|
||||||
|
if r.Drifted != 3 {
|
||||||
|
t.Errorf("drifted: want 3, got %d", r.Drifted)
|
||||||
|
}
|
||||||
|
wantRate := 3.0 / 5.0
|
||||||
|
if r.DriftRate < wantRate-1e-9 || r.DriftRate > wantRate+1e-9 {
|
||||||
|
t.Errorf("drift_rate: want %v, got %v", wantRate, r.DriftRate)
|
||||||
|
}
|
||||||
|
if len(r.Entries) != 3 {
|
||||||
|
t.Errorf("entries: want 3 mismatches, got %d", len(r.Entries))
|
||||||
|
}
|
||||||
|
// Shift matrix should show one shift: accepted → partially_accepted, count=3
|
||||||
|
if len(r.ShiftMatrix) != 1 {
|
||||||
|
t.Errorf("shift matrix: want 1 shift, got %d (%+v)", len(r.ShiftMatrix), r.ShiftMatrix)
|
||||||
|
} else {
|
||||||
|
s := r.ShiftMatrix[0]
|
||||||
|
if s.From != distillation.CategoryAccepted ||
|
||||||
|
s.To != distillation.CategoryPartiallyAccepted ||
|
||||||
|
s.Count != 3 {
|
||||||
|
t.Errorf("shift: got %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeScorerDrift_MultipleShiftsSortedByCount(t *testing.T) {
|
||||||
|
inputs := []ScorerDriftInput{
|
||||||
|
// 3× accepted→partial
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_2"}),
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_2"}),
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_2"}),
|
||||||
|
// 1× rejected→needs_human (no marker)
|
||||||
|
{
|
||||||
|
Record: distillation.EvidenceRecord{
|
||||||
|
RunID: "r1", TaskID: "t1",
|
||||||
|
Timestamp: "2026-01-01T00:00:00Z",
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: distillation.Provenance{
|
||||||
|
SourceFile: "data/_kb/scrum_reviews.jsonl",
|
||||||
|
SigHash: "x", RecordedAt: "2026-01-01T00:00:01Z",
|
||||||
|
},
|
||||||
|
// no markers → needs_human_review
|
||||||
|
},
|
||||||
|
PersistedCategory: distillation.CategoryRejected,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := ComputeScorerDrift(inputs, false)
|
||||||
|
if r.Drifted != 4 {
|
||||||
|
t.Errorf("drifted: want 4, got %d", r.Drifted)
|
||||||
|
}
|
||||||
|
if len(r.ShiftMatrix) != 2 {
|
||||||
|
t.Errorf("shift matrix: want 2 distinct shifts, got %d", len(r.ShiftMatrix))
|
||||||
|
}
|
||||||
|
// Sorted by count desc, so accepted→partial (3) before rejected→needs_human (1)
|
||||||
|
if r.ShiftMatrix[0].Count != 3 || r.ShiftMatrix[1].Count != 1 {
|
||||||
|
t.Errorf("shift order wrong: got %+v", r.ShiftMatrix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeScorerDrift_IncludeEntriesFalse(t *testing.T) {
|
||||||
|
inputs := []ScorerDriftInput{
|
||||||
|
mkInput("data/_kb/scrum_reviews.jsonl", distillation.CategoryAccepted, []string{"accepted_on_attempt_2"}),
|
||||||
|
}
|
||||||
|
r := ComputeScorerDrift(inputs, false)
|
||||||
|
if r.Drifted != 1 {
|
||||||
|
t.Errorf("drifted: want 1, got %d", r.Drifted)
|
||||||
|
}
|
||||||
|
if len(r.Entries) != 0 {
|
||||||
|
t.Errorf("entries: want 0 when includeEntries=false, got %d", len(r.Entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeScorerDrift_EmptyInput(t *testing.T) {
|
||||||
|
r := ComputeScorerDrift(nil, true)
|
||||||
|
if r.TotalChecked != 0 || r.Drifted != 0 || r.Matched != 0 {
|
||||||
|
t.Errorf("empty: want all-zero, got %+v", r)
|
||||||
|
}
|
||||||
|
if r.DriftRate != 0 {
|
||||||
|
t.Errorf("drift_rate on empty: want 0, got %v", r.DriftRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeScorerDrift_ScorerVersionStamped(t *testing.T) {
|
||||||
|
r := ComputeScorerDrift(nil, false)
|
||||||
|
if r.ScorerVersion != distillation.ScorerVersion {
|
||||||
|
t.Errorf("scorer_version: want %q, got %q", distillation.ScorerVersion, r.ScorerVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Distribution drift (PSI) tests ────────────────────────────────
|
||||||
|
|
||||||
|
// TestDistributionDrift_IdenticalIsStable: same data on both sides
|
||||||
|
// should yield PSI ≈ 0 and tier=stable. Anchors the lower bound.
|
||||||
|
func TestDistributionDrift_IdenticalIsStable(t *testing.T) {
|
||||||
|
data := []float64{0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9}
|
||||||
|
r := ComputeDistributionDrift(DistributionDriftInput{
|
||||||
|
Baseline: data,
|
||||||
|
Current: data,
|
||||||
|
NumBuckets: 5,
|
||||||
|
})
|
||||||
|
if r.PSI > 0.001 {
|
||||||
|
t.Errorf("identical distributions: expected PSI ≈ 0, got %f", r.PSI)
|
||||||
|
}
|
||||||
|
if r.Tier != DriftTierStable {
|
||||||
|
t.Errorf("expected stable tier, got %q", r.Tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDistributionDrift_HardShiftIsMajor: distribution moved
|
||||||
|
// completely to a different range — should yield major tier.
|
||||||
|
func TestDistributionDrift_HardShiftIsMajor(t *testing.T) {
|
||||||
|
baseline := []float64{0.1, 0.1, 0.2, 0.2, 0.3, 0.3, 0.4, 0.4}
|
||||||
|
current := []float64{0.7, 0.7, 0.8, 0.8, 0.9, 0.9, 1.0, 1.0}
|
||||||
|
r := ComputeDistributionDrift(DistributionDriftInput{
|
||||||
|
Baseline: baseline,
|
||||||
|
Current: current,
|
||||||
|
NumBuckets: 10,
|
||||||
|
})
|
||||||
|
if r.PSI < 0.25 {
|
||||||
|
t.Errorf("hard distribution shift: expected PSI ≥ 0.25, got %f", r.PSI)
|
||||||
|
}
|
||||||
|
if r.Tier != DriftTierMajor {
|
||||||
|
t.Errorf("expected major tier, got %q", r.Tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDistributionDrift_DetectsModerateShift: distribution shifted
|
||||||
|
// noticeably but not catastrophically — PSI must be > 0 (some drift
|
||||||
|
// detected) and tier must NOT be stable. Whether the tier is minor
|
||||||
|
// vs major depends on bucketing granularity; we don't pin that here
|
||||||
|
// because PSI thresholds are sensitive to bucket count.
|
||||||
|
func TestDistributionDrift_DetectsModerateShift(t *testing.T) {
|
||||||
|
// Baseline: many around 0.5, some spread.
|
||||||
|
baseline := []float64{0.4, 0.45, 0.5, 0.5, 0.5, 0.5, 0.5, 0.55, 0.6, 0.6,
|
||||||
|
0.45, 0.5, 0.5, 0.55, 0.5, 0.5, 0.5, 0.55, 0.5, 0.6}
|
||||||
|
// Current: same range, slight rightward shift (still overlapping).
|
||||||
|
current := []float64{0.45, 0.5, 0.5, 0.55, 0.55, 0.55, 0.6, 0.6, 0.65, 0.7,
|
||||||
|
0.5, 0.55, 0.55, 0.55, 0.6, 0.6, 0.55, 0.6, 0.65, 0.65}
|
||||||
|
r := ComputeDistributionDrift(DistributionDriftInput{
|
||||||
|
Baseline: baseline,
|
||||||
|
Current: current,
|
||||||
|
NumBuckets: 10,
|
||||||
|
})
|
||||||
|
if r.PSI < 0.01 {
|
||||||
|
t.Errorf("moderate shift should produce PSI > 0.01, got %f", r.PSI)
|
||||||
|
}
|
||||||
|
if r.Tier == DriftTierStable {
|
||||||
|
t.Errorf("moderate shift should NOT be stable tier, got PSI=%f tier=%q", r.PSI, r.Tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDistributionDrift_EmptyInputs: empty baseline OR current
|
||||||
|
// returns PSI=0, stable tier — caller must check N before trusting.
|
||||||
|
func TestDistributionDrift_EmptyInputs(t *testing.T) {
|
||||||
|
r := ComputeDistributionDrift(DistributionDriftInput{
|
||||||
|
Baseline: []float64{},
|
||||||
|
Current: []float64{1, 2, 3},
|
||||||
|
})
|
||||||
|
if r.PSI != 0 || r.Tier != DriftTierStable {
|
||||||
|
t.Errorf("empty baseline: expected PSI=0 stable, got psi=%f tier=%q", r.PSI, r.Tier)
|
||||||
|
}
|
||||||
|
r = ComputeDistributionDrift(DistributionDriftInput{
|
||||||
|
Baseline: []float64{1, 2, 3},
|
||||||
|
Current: []float64{},
|
||||||
|
})
|
||||||
|
if r.PSI != 0 || r.Tier != DriftTierStable {
|
||||||
|
t.Errorf("empty current: expected PSI=0 stable, got psi=%f tier=%q", r.PSI, r.Tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDistributionDrift_AllIdenticalValues: degenerate case where
|
||||||
|
// everything's the same value (e.g., all zeros). Should not panic;
|
||||||
|
// returns stable.
|
||||||
|
func TestDistributionDrift_AllIdenticalValues(t *testing.T) {
|
||||||
|
r := ComputeDistributionDrift(DistributionDriftInput{
|
||||||
|
Baseline: []float64{0.5, 0.5, 0.5},
|
||||||
|
Current: []float64{0.5, 0.5, 0.5},
|
||||||
|
})
|
||||||
|
if r.Tier != DriftTierStable {
|
||||||
|
t.Errorf("expected stable on identical-singleton, got %q", r.Tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDistributionDrift_BucketCounts: per-bucket counts must sum to
|
||||||
|
// the input N. If they don't, we're losing observations to bucket
|
||||||
|
// boundary issues.
|
||||||
|
func TestDistributionDrift_BucketCounts(t *testing.T) {
|
||||||
|
baseline := []float64{0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0}
|
||||||
|
current := []float64{0.5, 0.5, 0.5, 0.5}
|
||||||
|
r := ComputeDistributionDrift(DistributionDriftInput{
|
||||||
|
Baseline: baseline,
|
||||||
|
Current: current,
|
||||||
|
NumBuckets: 5,
|
||||||
|
})
|
||||||
|
totalB := 0
|
||||||
|
totalC := 0
|
||||||
|
for _, b := range r.Buckets {
|
||||||
|
totalB += b.BaselineCount
|
||||||
|
totalC += b.CurrentCount
|
||||||
|
}
|
||||||
|
if totalB != len(baseline) {
|
||||||
|
t.Errorf("baseline bucket counts sum to %d, expected %d", totalB, len(baseline))
|
||||||
|
}
|
||||||
|
if totalC != len(current) {
|
||||||
|
t.Errorf("current bucket counts sum to %d, expected %d", totalC, len(current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDistributionDrift_NumBucketsClamping: 0 → default 10; > 100 → 100.
|
||||||
|
func TestDistributionDrift_NumBucketsClamping(t *testing.T) {
|
||||||
|
in := DistributionDriftInput{Baseline: []float64{1, 2}, Current: []float64{1, 2}}
|
||||||
|
in.NumBuckets = 0
|
||||||
|
r := ComputeDistributionDrift(in)
|
||||||
|
if r.NumBuckets != 10 {
|
||||||
|
t.Errorf("0 should default to 10 buckets, got %d", r.NumBuckets)
|
||||||
|
}
|
||||||
|
in.NumBuckets = 500
|
||||||
|
r = ComputeDistributionDrift(in)
|
||||||
|
if r.NumBuckets != 100 {
|
||||||
|
t.Errorf("500 should clamp to 100 buckets, got %d", r.NumBuckets)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// cached.go — LRU caching wrapper around a Provider.
|
||||||
|
//
|
||||||
|
// Memoizes (effective_model, sha256(text)) → []float32. Repeat
|
||||||
|
// queries return the stored vector without round-tripping to the
|
||||||
|
// upstream embedding service. Per-text caching: a batch with
|
||||||
|
// mixed hit/miss only fetches the misses, then merges the result
|
||||||
|
// preserving the caller's input order.
|
||||||
|
//
|
||||||
|
// Memory budget: ~3 KB per entry at d=768. 10K-entry default ≈ 30 MB
|
||||||
|
// — small enough for any realistic embedd deployment. Operators
|
||||||
|
// raising the cap should weigh memory headroom against expected
|
||||||
|
// hit rate.
|
||||||
|
//
|
||||||
|
// Why this exists: 500K staffing test (memory project_golang_lakehouse)
|
||||||
|
// showed that the staffing co-pilot replays many of the same query
|
||||||
|
// texts ("forklift driver", "welder Chicago", etc.). Caching them
|
||||||
|
// drops repeat-query cost from ~50ms (Ollama round-trip) to <1µs
|
||||||
|
// (LRU hit). Real production win documented in feedback_meta_index_vision.
|
||||||
|
|
||||||
|
package embed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CachedProvider wraps a Provider with an LRU cache keyed on
|
||||||
|
// (effective_model, sha256(text)). Thread-safe.
|
||||||
|
type CachedProvider struct {
|
||||||
|
inner Provider
|
||||||
|
defaultModel string
|
||||||
|
cache *lru.Cache[string, []float32]
|
||||||
|
hits atomic.Int64
|
||||||
|
misses atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCachedProvider wraps inner with an LRU cache of the given size.
|
||||||
|
//
|
||||||
|
// defaultModel is used to resolve cache keys when a request leaves
|
||||||
|
// the model field empty: a request for model="" is treated as
|
||||||
|
// model=defaultModel for cache-key purposes, so callers that mix
|
||||||
|
// "" and the explicit model name still hit the same cache entry.
|
||||||
|
//
|
||||||
|
// Caller-side panic protection: size <= 0 is treated as "no cache"
|
||||||
|
// — every Embed call passes through. Avoids forcing operators to
|
||||||
|
// understand LRU sizing to disable.
|
||||||
|
func NewCachedProvider(inner Provider, defaultModel string, size int) (*CachedProvider, error) {
|
||||||
|
if size <= 0 {
|
||||||
|
// Sentinel: nil cache means pass-through. NewCachedProvider
|
||||||
|
// stays callable so the wiring layer can always wrap.
|
||||||
|
return &CachedProvider{inner: inner, defaultModel: defaultModel}, nil
|
||||||
|
}
|
||||||
|
cache, err := lru.New[string, []float32](size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("embed cache init: %w", err)
|
||||||
|
}
|
||||||
|
return &CachedProvider{
|
||||||
|
inner: inner,
|
||||||
|
defaultModel: defaultModel,
|
||||||
|
cache: cache,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed returns vectors for texts, memoizing per (model, text). On
|
||||||
|
// a batch with mixed hits/misses, only the misses round-trip to
|
||||||
|
// inner; the result preserves caller ordering.
|
||||||
|
func (c *CachedProvider) Embed(ctx context.Context, texts []string, model string) (Result, error) {
|
||||||
|
// Pass-through when caching disabled.
|
||||||
|
if c.cache == nil {
|
||||||
|
return c.inner.Embed(ctx, texts, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(texts) == 0 {
|
||||||
|
return Result{}, ErrEmptyTexts
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveModel := model
|
||||||
|
if effectiveModel == "" {
|
||||||
|
effectiveModel = c.defaultModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: cache lookup; collect misses preserving original index
|
||||||
|
// so we can write the upstream result back into the right slots.
|
||||||
|
out := make([][]float32, len(texts))
|
||||||
|
missTexts := make([]string, 0, len(texts))
|
||||||
|
missIdx := make([]int, 0, len(texts))
|
||||||
|
for i, t := range texts {
|
||||||
|
key := cacheKey(effectiveModel, t)
|
||||||
|
if v, ok := c.cache.Get(key); ok {
|
||||||
|
out[i] = v
|
||||||
|
c.hits.Add(1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
missTexts = append(missTexts, t)
|
||||||
|
missIdx = append(missIdx, i)
|
||||||
|
c.misses.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All hits — synthesize the result without an upstream call.
|
||||||
|
// Use the effective model + the first cached vector's length
|
||||||
|
// for the response. Every cached vector for the same model has
|
||||||
|
// the same dimension by construction (Provider guarantees it).
|
||||||
|
if len(missTexts) == 0 {
|
||||||
|
dim := 0
|
||||||
|
if len(out) > 0 && len(out[0]) > 0 {
|
||||||
|
dim = len(out[0])
|
||||||
|
}
|
||||||
|
return Result{
|
||||||
|
Model: effectiveModel,
|
||||||
|
Dimension: dim,
|
||||||
|
Vectors: out,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: fetch the misses, populate cache, merge.
|
||||||
|
res, err := c.inner.Embed(ctx, missTexts, model)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if len(res.Vectors) != len(missTexts) {
|
||||||
|
return Result{}, fmt.Errorf("embed cache: provider returned %d vectors for %d miss texts",
|
||||||
|
len(res.Vectors), len(missTexts))
|
||||||
|
}
|
||||||
|
// Cache under effectiveModel (request-derived), NOT res.Model
|
||||||
|
// (upstream-derived). Future requests with the same input shape
|
||||||
|
// — same explicit model OR the same "" — must hit the same key.
|
||||||
|
// Only ours is predictable from the request; upstream's resolution
|
||||||
|
// can drift if the upstream's default changes.
|
||||||
|
for j, t := range missTexts {
|
||||||
|
out[missIdx[j]] = res.Vectors[j]
|
||||||
|
c.cache.Add(cacheKey(effectiveModel, t), res.Vectors[j])
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{
|
||||||
|
Model: res.Model,
|
||||||
|
Dimension: res.Dimension,
|
||||||
|
Vectors: out,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns lifetime hit + miss counts for this cache. Atomic
|
||||||
|
// reads — no locking. Useful for /health, /metrics, or operator
|
||||||
|
// dashboards.
|
||||||
|
func (c *CachedProvider) Stats() (hits, misses int64) {
|
||||||
|
return c.hits.Load(), c.misses.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HitRate returns the fraction of requests served from cache.
|
||||||
|
// Returns 0.0 when no requests have been served (avoids NaN).
|
||||||
|
func (c *CachedProvider) HitRate() float64 {
|
||||||
|
h := c.hits.Load()
|
||||||
|
m := c.misses.Load()
|
||||||
|
total := h + m
|
||||||
|
if total == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
return float64(h) / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the current entry count in the cache. Returns 0 when
|
||||||
|
// caching is disabled.
|
||||||
|
func (c *CachedProvider) Len() int {
|
||||||
|
if c.cache == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return c.cache.Len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheKey is "<model>:<sha256(text)>". sha256 collapses long texts
|
||||||
|
// to a fixed-size key; model prefix scopes cache to one model so
|
||||||
|
// callers using multiple models don't get cross-contamination.
|
||||||
|
func cacheKey(model, text string) string {
|
||||||
|
h := sha256.Sum256([]byte(text))
|
||||||
|
return model + ":" + hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
package embed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubProvider records every call so tests can verify caching
|
||||||
|
// behavior (call counts, request texts) without spinning up Ollama.
|
||||||
|
type stubProvider struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
model string
|
||||||
|
dim int
|
||||||
|
callCount int
|
||||||
|
lastTexts []string
|
||||||
|
err error
|
||||||
|
// vectorFn returns a deterministic vector for a given text so
|
||||||
|
// cache hits assert byte-equality, not just shape.
|
||||||
|
vectorFn func(text string) []float32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubProvider) Embed(_ context.Context, texts []string, model string) (Result, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.callCount++
|
||||||
|
s.lastTexts = append([]string(nil), texts...)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if s.err != nil {
|
||||||
|
return Result{}, s.err
|
||||||
|
}
|
||||||
|
out := make([][]float32, len(texts))
|
||||||
|
for i, t := range texts {
|
||||||
|
out[i] = s.vectorFn(t)
|
||||||
|
}
|
||||||
|
resolvedModel := model
|
||||||
|
if resolvedModel == "" {
|
||||||
|
resolvedModel = s.model
|
||||||
|
}
|
||||||
|
return Result{Model: resolvedModel, Dimension: s.dim, Vectors: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deterministicVector(t string) []float32 {
|
||||||
|
v := make([]float32, 4)
|
||||||
|
for i := range v {
|
||||||
|
v[i] = float32(int(t[0]) + i)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStub() *stubProvider {
|
||||||
|
return &stubProvider{
|
||||||
|
model: "test-model",
|
||||||
|
dim: 4,
|
||||||
|
vectorFn: deterministicVector,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_PassThroughWhenSizeZero(t *testing.T) {
|
||||||
|
stub := newStub()
|
||||||
|
c, err := NewCachedProvider(stub, "test-model", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCachedProvider: %v", err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, err := c.Embed(context.Background(), []string{"hello"}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Embed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stub.callCount != 3 {
|
||||||
|
t.Errorf("size=0 should pass through every call, got callCount=%d", stub.callCount)
|
||||||
|
}
|
||||||
|
if c.Len() != 0 {
|
||||||
|
t.Errorf("Len()=%d, want 0 when caching disabled", c.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_HitOnRepeatText(t *testing.T) {
|
||||||
|
stub := newStub()
|
||||||
|
c, err := NewCachedProvider(stub, "test-model", 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCachedProvider: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First call — miss, hits upstream.
|
||||||
|
r1, err := c.Embed(context.Background(), []string{"hello"}, "test-model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first: %v", err)
|
||||||
|
}
|
||||||
|
if stub.callCount != 1 {
|
||||||
|
t.Errorf("first call should hit upstream, callCount=%d", stub.callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call — hit, no upstream call.
|
||||||
|
r2, err := c.Embed(context.Background(), []string{"hello"}, "test-model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second: %v", err)
|
||||||
|
}
|
||||||
|
if stub.callCount != 1 {
|
||||||
|
t.Errorf("second call should hit cache, callCount=%d (want unchanged 1)", stub.callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result should be byte-equal.
|
||||||
|
if len(r1.Vectors[0]) != len(r2.Vectors[0]) {
|
||||||
|
t.Errorf("vector dim differs: %d vs %d", len(r1.Vectors[0]), len(r2.Vectors[0]))
|
||||||
|
}
|
||||||
|
for i := range r1.Vectors[0] {
|
||||||
|
if r1.Vectors[0][i] != r2.Vectors[0][i] {
|
||||||
|
t.Errorf("vector[%d]: %v vs %v", i, r1.Vectors[0][i], r2.Vectors[0][i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hits, misses := c.Stats()
|
||||||
|
if hits != 1 || misses != 1 {
|
||||||
|
t.Errorf("hits/misses = %d/%d, want 1/1", hits, misses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_MixedBatchOnlyFetchesMisses(t *testing.T) {
|
||||||
|
stub := newStub()
|
||||||
|
c, _ := NewCachedProvider(stub, "test-model", 100)
|
||||||
|
|
||||||
|
// Prime cache with one text.
|
||||||
|
c.Embed(context.Background(), []string{"alpha"}, "test-model")
|
||||||
|
if stub.callCount != 1 {
|
||||||
|
t.Fatalf("priming should produce 1 upstream call, got %d", stub.callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed batch: alpha cached, beta + gamma fresh.
|
||||||
|
r, err := c.Embed(context.Background(),
|
||||||
|
[]string{"alpha", "beta", "gamma"}, "test-model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Embed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider should have been called only with the misses.
|
||||||
|
if len(stub.lastTexts) != 2 {
|
||||||
|
t.Errorf("upstream called with %d texts, want 2 (only misses)", len(stub.lastTexts))
|
||||||
|
}
|
||||||
|
for _, lt := range stub.lastTexts {
|
||||||
|
if lt == "alpha" {
|
||||||
|
t.Errorf("upstream got 'alpha' but it was cached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result preserves order: alpha first (cached), beta + gamma in
|
||||||
|
// their original positions.
|
||||||
|
if len(r.Vectors) != 3 {
|
||||||
|
t.Fatalf("got %d vectors, want 3", len(r.Vectors))
|
||||||
|
}
|
||||||
|
for i, txt := range []string{"alpha", "beta", "gamma"} {
|
||||||
|
want := deterministicVector(txt)
|
||||||
|
for j := range want {
|
||||||
|
if r.Vectors[i][j] != want[j] {
|
||||||
|
t.Errorf("vec[%d (%s)][%d] = %v, want %v",
|
||||||
|
i, txt, j, r.Vectors[i][j], want[j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_ModelKeyIsolation(t *testing.T) {
|
||||||
|
stub := newStub()
|
||||||
|
c, _ := NewCachedProvider(stub, "default-model", 100)
|
||||||
|
|
||||||
|
// Same text, different models → distinct cache entries.
|
||||||
|
c.Embed(context.Background(), []string{"shared"}, "model-A")
|
||||||
|
c.Embed(context.Background(), []string{"shared"}, "model-B")
|
||||||
|
if stub.callCount != 2 {
|
||||||
|
t.Errorf("different models should NOT share cache, got callCount=%d", stub.callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repeat with model-A → hits cache.
|
||||||
|
c.Embed(context.Background(), []string{"shared"}, "model-A")
|
||||||
|
if stub.callCount != 2 {
|
||||||
|
t.Errorf("repeat of model-A should hit cache, got callCount=%d", stub.callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_EmptyModelResolvesToDefault(t *testing.T) {
|
||||||
|
stub := newStub()
|
||||||
|
c, _ := NewCachedProvider(stub, "the-default", 100)
|
||||||
|
|
||||||
|
// First call with model="" — caches under "the-default".
|
||||||
|
c.Embed(context.Background(), []string{"text"}, "")
|
||||||
|
if stub.callCount != 1 {
|
||||||
|
t.Fatalf("first call should hit upstream, got %d", stub.callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call with model="the-default" — should hit cache because
|
||||||
|
// the first call resolved "" → "the-default" for the key.
|
||||||
|
c.Embed(context.Background(), []string{"text"}, "the-default")
|
||||||
|
if stub.callCount != 1 {
|
||||||
|
t.Errorf("explicit default-model should hit cache populated by '' request, got callCount=%d", stub.callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_LRUEviction(t *testing.T) {
|
||||||
|
stub := newStub()
|
||||||
|
c, _ := NewCachedProvider(stub, "test-model", 2) // cap=2
|
||||||
|
|
||||||
|
// Fill: a, b
|
||||||
|
c.Embed(context.Background(), []string{"a"}, "")
|
||||||
|
c.Embed(context.Background(), []string{"b"}, "")
|
||||||
|
// c arrives — evicts a (LRU).
|
||||||
|
c.Embed(context.Background(), []string{"c"}, "")
|
||||||
|
if c.Len() > 2 {
|
||||||
|
t.Errorf("Len()=%d, want ≤2 with cap=2", c.Len())
|
||||||
|
}
|
||||||
|
if stub.callCount != 3 {
|
||||||
|
t.Errorf("callCount=%d, want 3 after a/b/c distinct", stub.callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-request a → miss (was evicted).
|
||||||
|
c.Embed(context.Background(), []string{"a"}, "")
|
||||||
|
if stub.callCount != 4 {
|
||||||
|
t.Errorf("evicted a should miss, callCount=%d want 4", stub.callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_PropagatesUpstreamError(t *testing.T) {
|
||||||
|
stub := &stubProvider{
|
||||||
|
model: "test-model",
|
||||||
|
dim: 4,
|
||||||
|
vectorFn: deterministicVector,
|
||||||
|
err: errors.New("upstream broken"),
|
||||||
|
}
|
||||||
|
c, _ := NewCachedProvider(stub, "test-model", 10)
|
||||||
|
|
||||||
|
_, err := c.Embed(context.Background(), []string{"hello"}, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected upstream error to propagate")
|
||||||
|
}
|
||||||
|
// Cache should NOT have stored anything on error.
|
||||||
|
if c.Len() != 0 {
|
||||||
|
t.Errorf("error path should not populate cache, Len()=%d", c.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_AllHitsSynthesized(t *testing.T) {
|
||||||
|
stub := newStub()
|
||||||
|
c, _ := NewCachedProvider(stub, "test-model", 100)
|
||||||
|
|
||||||
|
// Prime
|
||||||
|
c.Embed(context.Background(), []string{"x", "y"}, "")
|
||||||
|
stub.mu.Lock()
|
||||||
|
stub.callCount = 0 // reset for clarity
|
||||||
|
stub.mu.Unlock()
|
||||||
|
|
||||||
|
// All-hits batch → no upstream call.
|
||||||
|
r, err := c.Embed(context.Background(), []string{"x", "y"}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Embed: %v", err)
|
||||||
|
}
|
||||||
|
if stub.callCount != 0 {
|
||||||
|
t.Errorf("all-hits batch should not hit upstream, callCount=%d", stub.callCount)
|
||||||
|
}
|
||||||
|
if r.Dimension != 4 {
|
||||||
|
t.Errorf("all-hits Dimension=%d, want 4 (from cached vector len)", r.Dimension)
|
||||||
|
}
|
||||||
|
if r.Model != "test-model" {
|
||||||
|
t.Errorf("all-hits Model=%q, want resolved 'test-model'", r.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_HitRate(t *testing.T) {
|
||||||
|
stub := newStub()
|
||||||
|
c, _ := NewCachedProvider(stub, "test-model", 100)
|
||||||
|
|
||||||
|
if c.HitRate() != 0.0 {
|
||||||
|
t.Errorf("initial HitRate=%v, want 0.0", c.HitRate())
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Embed(context.Background(), []string{"a"}, "") // miss
|
||||||
|
c.Embed(context.Background(), []string{"a"}, "") // hit
|
||||||
|
c.Embed(context.Background(), []string{"a"}, "") // hit
|
||||||
|
|
||||||
|
got := c.HitRate()
|
||||||
|
want := 2.0 / 3.0
|
||||||
|
if got < want-0.01 || got > want+0.01 {
|
||||||
|
t.Errorf("HitRate=%v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_EmptyTextsRejected(t *testing.T) {
|
||||||
|
stub := newStub()
|
||||||
|
c, _ := NewCachedProvider(stub, "test-model", 100)
|
||||||
|
|
||||||
|
_, err := c.Embed(context.Background(), []string{}, "")
|
||||||
|
if !errors.Is(err, ErrEmptyTexts) {
|
||||||
|
t.Errorf("empty texts should return ErrEmptyTexts, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedProvider_ConcurrentSafe(t *testing.T) {
|
||||||
|
stub := newStub()
|
||||||
|
c, _ := NewCachedProvider(stub, "test-model", 100)
|
||||||
|
|
||||||
|
// 50 goroutines × 100 calls each, mostly the same text.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for g := 0; g < 50; g++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
_, err := c.Embed(context.Background(), []string{"shared"}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("concurrent embed err: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(g)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
hits, misses := c.Stats()
|
||||||
|
if hits+misses != 5000 {
|
||||||
|
t.Errorf("total calls = %d, want 5000", hits+misses)
|
||||||
|
}
|
||||||
|
// First call is a guaranteed miss; subsequent should mostly be
|
||||||
|
// hits. With 50 concurrent goroutines hitting an empty cache,
|
||||||
|
// up to ~50 first-arrivers may miss before the first one writes.
|
||||||
|
if misses > 100 {
|
||||||
|
t.Errorf("misses=%d unreasonably high; cache concurrency broken?", misses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheKey_StableAndDistinct(t *testing.T) {
|
||||||
|
a := cacheKey("m1", "text-a")
|
||||||
|
a2 := cacheKey("m1", "text-a")
|
||||||
|
b := cacheKey("m1", "text-b")
|
||||||
|
c := cacheKey("m2", "text-a")
|
||||||
|
|
||||||
|
if a != a2 {
|
||||||
|
t.Errorf("cache key not stable: %q != %q", a, a2)
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
t.Errorf("different texts produced same key: %q", a)
|
||||||
|
}
|
||||||
|
if a == c {
|
||||||
|
t.Errorf("different models produced same key: %q", a)
|
||||||
|
}
|
||||||
|
// Key shape: model + ":" + 64-hex-chars sha256.
|
||||||
|
const sha256HexLen = 64
|
||||||
|
want := len("m1") + 1 + sha256HexLen
|
||||||
|
if len(a) != want {
|
||||||
|
t.Errorf("key len=%d, want %d", len(a), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Package langfuse is a minimal Go-side client for the Langfuse v2
|
||||||
|
// ingestion API. Mirrors the surface area we need from the Rust
|
||||||
|
// crates/gateway/src/v1/langfuse_trace.rs emitter — Trace + Span,
|
||||||
|
// nothing else yet (no scores, no observations, no datasets).
|
||||||
|
//
|
||||||
|
// Auth is Basic over public_key:secret_key. URL + creds come from
|
||||||
|
// /etc/lakehouse/langfuse.env in production; tests can pass any URL.
|
||||||
|
//
|
||||||
|
// Best-effort transport: errors are logged but don't fail the calling
|
||||||
|
// path. Lakehouse's internal services should never go down because
|
||||||
|
// Langfuse is unreachable.
|
||||||
|
package langfuse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client posts traces + spans to Langfuse's ingestion endpoint.
|
||||||
|
// Events are buffered and flushed in batches. Always call Flush
|
||||||
|
// before exit; Close also flushes.
|
||||||
|
type Client struct {
|
||||||
|
url string
|
||||||
|
auth string // pre-encoded "Basic ..."
|
||||||
|
hc *http.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
pending []event
|
||||||
|
maxBatch int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Client. URL like "http://localhost:3001"; creds
|
||||||
|
// from langfuse.env. nil hc → uses default with 5s timeout.
|
||||||
|
func New(url, publicKey, secretKey string, hc *http.Client) *Client {
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{Timeout: 5 * time.Second}
|
||||||
|
}
|
||||||
|
auth := "Basic " + base64.StdEncoding.EncodeToString([]byte(publicKey+":"+secretKey))
|
||||||
|
return &Client{
|
||||||
|
url: url,
|
||||||
|
auth: auth,
|
||||||
|
hc: hc,
|
||||||
|
maxBatch: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewID returns a hex string suitable as a trace/span id. Langfuse
|
||||||
|
// accepts arbitrary strings; a 16-byte random hex is unambiguous.
|
||||||
|
func NewID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// event is one Langfuse ingestion envelope. Body shape varies by
|
||||||
|
// type (trace-create vs span-create); we use map[string]any to
|
||||||
|
// keep the wire shape declarative.
|
||||||
|
type event struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // "trace-create" | "span-create"
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Body map[string]any `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TraceInput is what callers fill in when starting a trace.
|
||||||
|
type TraceInput struct {
|
||||||
|
Name string
|
||||||
|
UserID string
|
||||||
|
Input any
|
||||||
|
Metadata map[string]any
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace records a top-level trace. Returns the trace id so callers
|
||||||
|
// can attach spans. Best-effort: errors are logged and the trace
|
||||||
|
// id is still returned so callers don't need error-handling for the
|
||||||
|
// common case.
|
||||||
|
func (c *Client) Trace(ctx context.Context, t TraceInput) string {
|
||||||
|
id := NewID()
|
||||||
|
body := map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"name": t.Name,
|
||||||
|
}
|
||||||
|
if t.UserID != "" {
|
||||||
|
body["userId"] = t.UserID
|
||||||
|
}
|
||||||
|
if t.Input != nil {
|
||||||
|
body["input"] = t.Input
|
||||||
|
}
|
||||||
|
if t.Metadata != nil {
|
||||||
|
body["metadata"] = t.Metadata
|
||||||
|
}
|
||||||
|
if len(t.Tags) > 0 {
|
||||||
|
body["tags"] = t.Tags
|
||||||
|
}
|
||||||
|
c.queue(event{
|
||||||
|
ID: NewID(),
|
||||||
|
Type: "trace-create",
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpanInput is what callers fill in when recording a span.
|
||||||
|
type SpanInput struct {
|
||||||
|
TraceID string
|
||||||
|
ParentID string // optional — for nested spans
|
||||||
|
Name string
|
||||||
|
Input any
|
||||||
|
Output any
|
||||||
|
Metadata map[string]any
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time
|
||||||
|
StatusCode int // 0 = success, anything else = error code
|
||||||
|
Level string // "DEBUG" | "DEFAULT" | "WARNING" | "ERROR"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Span records one span attached to a trace. Returns the span id.
|
||||||
|
func (c *Client) Span(ctx context.Context, s SpanInput) string {
|
||||||
|
id := NewID()
|
||||||
|
body := map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"traceId": s.TraceID,
|
||||||
|
"name": s.Name,
|
||||||
|
}
|
||||||
|
if s.ParentID != "" {
|
||||||
|
body["parentObservationId"] = s.ParentID
|
||||||
|
}
|
||||||
|
if s.Input != nil {
|
||||||
|
body["input"] = s.Input
|
||||||
|
}
|
||||||
|
if s.Output != nil {
|
||||||
|
body["output"] = s.Output
|
||||||
|
}
|
||||||
|
if s.Metadata != nil {
|
||||||
|
body["metadata"] = s.Metadata
|
||||||
|
}
|
||||||
|
if !s.StartTime.IsZero() {
|
||||||
|
body["startTime"] = s.StartTime.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
if !s.EndTime.IsZero() {
|
||||||
|
body["endTime"] = s.EndTime.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
if s.Level != "" {
|
||||||
|
body["level"] = s.Level
|
||||||
|
}
|
||||||
|
if s.StatusCode != 0 {
|
||||||
|
body["statusMessage"] = fmt.Sprintf("status_code=%d", s.StatusCode)
|
||||||
|
}
|
||||||
|
c.queue(event{
|
||||||
|
ID: NewID(),
|
||||||
|
Type: "span-create",
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) queue(e event) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.pending = append(c.pending, e)
|
||||||
|
shouldFlush := len(c.pending) >= c.maxBatch
|
||||||
|
c.mu.Unlock()
|
||||||
|
if shouldFlush {
|
||||||
|
// Fire-and-forget so the calling goroutine isn't blocked by
|
||||||
|
// the 5s HTTP timeout when Langfuse hiccups. Per scrum (Opus
|
||||||
|
// WARN): synchronous flush from queue defeated the
|
||||||
|
// "best-effort, never blocks calling path" guarantee in the
|
||||||
|
// package doc.
|
||||||
|
go func() { _ = c.Flush(context.Background()) }()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush sends all queued events in one batch. Best-effort: returns
|
||||||
|
// the error but also logs; callers can ignore.
|
||||||
|
func (c *Client) Flush(ctx context.Context) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
if len(c.pending) == 0 {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
batch := c.pending
|
||||||
|
c.pending = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]any{"batch": batch})
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("langfuse: marshal batch", "err", err, "n", len(batch))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", c.url+"/api/public/ingestion", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", c.auth)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("langfuse: post", "err", err, "n", len(batch))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 && resp.StatusCode != 207 {
|
||||||
|
slog.Warn("langfuse: non-2xx", "status", resp.StatusCode, "n", len(batch))
|
||||||
|
return fmt.Errorf("langfuse ingestion: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close flushes any remaining events. Idempotent.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
return c.Flush(context.Background())
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Package materializer ports scripts/distillation/transforms.ts +
|
||||||
|
// build_evidence_index.ts to Go. Source rows in data/_kb/*.jsonl are
|
||||||
|
// transformed into EvidenceRecord rows under data/evidence/YYYY/MM/DD/.
|
||||||
|
//
|
||||||
|
// Per ADR-001 #4: port LOGIC, not bit-identical reproducibility — but
|
||||||
|
// on-wire JSON layout matches the TS shape so Bun and Go runs stay
|
||||||
|
// interchangeable for tooling that reads either output.
|
||||||
|
package materializer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CanonicalSha256 returns the hex SHA-256 of `obj` after sorting all
|
||||||
|
// object keys recursively. Matches the TS canonicalSha256 in
|
||||||
|
// auditor/schemas/distillation/types.ts so a row hashed by either
|
||||||
|
// runtime gets the same sig_hash.
|
||||||
|
//
|
||||||
|
// Determinism contract: identical input → identical hash, regardless
|
||||||
|
// of the producer's serialization order.
|
||||||
|
func CanonicalSha256(obj any) (string, error) {
|
||||||
|
ordered := orderKeys(obj)
|
||||||
|
buf, err := json.Marshal(ordered)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("canonical marshal: %w", err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(buf)
|
||||||
|
return hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderKeys recursively sorts every map's keys. For arrays we keep the
|
||||||
|
// element order (arrays are inherently ordered). Scalars pass through.
|
||||||
|
func orderKeys(v any) any {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
keys := make([]string, 0, len(t))
|
||||||
|
for k := range t {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
out := make(orderedMap, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
out = append(out, kvPair{Key: k, Value: orderKeys(t[k])})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(t))
|
||||||
|
for i, e := range t {
|
||||||
|
out[i] = orderKeys(e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderedMap preserves insertion order on JSON marshal. We populate it
|
||||||
|
// in sorted-key order so the produced bytes are stable.
|
||||||
|
type orderedMap []kvPair
|
||||||
|
|
||||||
|
type kvPair struct {
|
||||||
|
Key string
|
||||||
|
Value any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (om orderedMap) MarshalJSON() ([]byte, error) {
|
||||||
|
if len(om) == 0 {
|
||||||
|
return []byte("{}"), nil
|
||||||
|
}
|
||||||
|
out := []byte{'{'}
|
||||||
|
for i, kv := range om {
|
||||||
|
if i > 0 {
|
||||||
|
out = append(out, ',')
|
||||||
|
}
|
||||||
|
k, err := json.Marshal(kv.Key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, k...)
|
||||||
|
out = append(out, ':')
|
||||||
|
v, err := json.Marshal(kv.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, v...)
|
||||||
|
}
|
||||||
|
out = append(out, '}')
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package materializer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanonicalSha256_StableAcrossMapOrder(t *testing.T) {
|
||||||
|
a := map[string]any{"b": 2, "a": 1, "c": map[string]any{"y": "Y", "x": "X"}}
|
||||||
|
b := map[string]any{"a": 1, "c": map[string]any{"x": "X", "y": "Y"}, "b": 2}
|
||||||
|
hashA, err := CanonicalSha256(a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash a: %v", err)
|
||||||
|
}
|
||||||
|
hashB, err := CanonicalSha256(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash b: %v", err)
|
||||||
|
}
|
||||||
|
if hashA != hashB {
|
||||||
|
t.Fatalf("identical objects produced different hashes:\n a=%s\n b=%s", hashA, hashB)
|
||||||
|
}
|
||||||
|
if len(hashA) != 64 || strings.Trim(hashA, "0123456789abcdef") != "" {
|
||||||
|
t.Fatalf("hash isn't a 64-char hex string: %q", hashA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalSha256_DistinctsDifferentInputs(t *testing.T) {
|
||||||
|
a := map[string]any{"k": "v"}
|
||||||
|
b := map[string]any{"k": "v2"}
|
||||||
|
hashA, _ := CanonicalSha256(a)
|
||||||
|
hashB, _ := CanonicalSha256(b)
|
||||||
|
if hashA == hashB {
|
||||||
|
t.Fatalf("different inputs collided: %s", hashA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalSha256_ArrayOrderMatters(t *testing.T) {
|
||||||
|
a := map[string]any{"k": []any{1, 2, 3}}
|
||||||
|
b := map[string]any{"k": []any{3, 2, 1}}
|
||||||
|
hashA, _ := CanonicalSha256(a)
|
||||||
|
hashB, _ := CanonicalSha256(b)
|
||||||
|
if hashA == hashB {
|
||||||
|
t.Fatal("array order should change the hash, but did not")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
package materializer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaterializeOptions drives MaterializeAll. Tests construct this with
|
||||||
|
// a temp Root and override Transforms; the CLI uses defaults.
|
||||||
|
type MaterializeOptions struct {
|
||||||
|
Root string // repo root; sources + outputs are relative
|
||||||
|
Transforms []TransformDef // override for tests
|
||||||
|
RecordedAt string // ISO 8601 — fixed for the run
|
||||||
|
DryRun bool // count but don't write
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceResult mirrors TS SourceResult.
|
||||||
|
type SourceResult struct {
|
||||||
|
SourceFileRelPath string `json:"source_file_relpath"`
|
||||||
|
RowsPresent bool `json:"rows_present"`
|
||||||
|
RowsRead int `json:"rows_read"`
|
||||||
|
RowsWritten int `json:"rows_written"`
|
||||||
|
RowsSkipped int `json:"rows_skipped"`
|
||||||
|
RowsDeduped int `json:"rows_deduped"`
|
||||||
|
OutputFiles []string `json:"output_files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaterializeResult is what MaterializeAll returns. Receipt is the
|
||||||
|
// authoritative "did the run succeed" surface — the rest is plumbing.
|
||||||
|
type MaterializeResult struct {
|
||||||
|
Sources []SourceResult `json:"sources"`
|
||||||
|
Totals Totals `json:"totals"`
|
||||||
|
Receipt Receipt `json:"receipt"`
|
||||||
|
ReceiptPath string `json:"receipt_path"`
|
||||||
|
EvidenceDir string `json:"evidence_dir"`
|
||||||
|
SkipsPath string `json:"skips_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals — flat sum across sources.
|
||||||
|
type Totals struct {
|
||||||
|
RowsRead int `json:"rows_read"`
|
||||||
|
RowsWritten int `json:"rows_written"`
|
||||||
|
RowsSkipped int `json:"rows_skipped"`
|
||||||
|
RowsDeduped int `json:"rows_deduped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receipt mirrors auditor/schemas/distillation/receipt.ts. Schema
|
||||||
|
// version pinned to match the TS producer so consumers see the same
|
||||||
|
// shape regardless of which runtime generated the run.
|
||||||
|
const ReceiptSchemaVersion = 1
|
||||||
|
|
||||||
|
type Receipt struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
GitSHA string `json:"git_sha"`
|
||||||
|
GitBranch string `json:"git_branch,omitempty"`
|
||||||
|
GitDirty bool `json:"git_dirty"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
EndedAt string `json:"ended_at"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
InputFiles []FileReference `json:"input_files"`
|
||||||
|
OutputFiles []FileReference `json:"output_files"`
|
||||||
|
RecordCounts RecordCounts `json:"record_counts"`
|
||||||
|
ValidationPass bool `json:"validation_pass"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileReference struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordCounts struct {
|
||||||
|
In int `json:"in"`
|
||||||
|
Out int `json:"out"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Deduped int `json:"deduped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkipRecord is one row in distillation_skips.jsonl. Operators read
|
||||||
|
// this stream when a run reports rows_skipped > 0.
|
||||||
|
type SkipRecord struct {
|
||||||
|
SourceFile string `json:"source_file"`
|
||||||
|
LineOffset int64 `json:"line_offset"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
SigHash string `json:"sig_hash,omitempty"`
|
||||||
|
RecordedAt string `json:"recorded_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaterializeAll iterates Transforms[], reads each source JSONL,
|
||||||
|
// transforms each row, validates, writes to date-partitioned output.
|
||||||
|
// Returns a Receipt whose ValidationPass tells the caller whether all
|
||||||
|
// rows survived validation.
|
||||||
|
func MaterializeAll(opts MaterializeOptions) (MaterializeResult, error) {
|
||||||
|
if opts.RecordedAt == "" {
|
||||||
|
return MaterializeResult{}, errors.New("MaterializeOptions.RecordedAt required")
|
||||||
|
}
|
||||||
|
if opts.Root == "" {
|
||||||
|
return MaterializeResult{}, errors.New("MaterializeOptions.Root required")
|
||||||
|
}
|
||||||
|
if !validISOTimestamp(opts.RecordedAt) {
|
||||||
|
return MaterializeResult{}, fmt.Errorf("RecordedAt not ISO 8601: %s", opts.RecordedAt)
|
||||||
|
}
|
||||||
|
transforms := opts.Transforms
|
||||||
|
if transforms == nil {
|
||||||
|
transforms = Transforms
|
||||||
|
}
|
||||||
|
|
||||||
|
evidenceDir := filepath.Join(opts.Root, "data", "evidence")
|
||||||
|
skipsPath := filepath.Join(opts.Root, "data", "_kb", "distillation_skips.jsonl")
|
||||||
|
reportsDir := filepath.Join(opts.Root, "reports", "distillation")
|
||||||
|
|
||||||
|
startedMs := time.Now().UnixMilli()
|
||||||
|
sources := make([]SourceResult, 0, len(transforms))
|
||||||
|
for _, t := range transforms {
|
||||||
|
sr, err := processSource(t, opts, evidenceDir, skipsPath)
|
||||||
|
if err != nil {
|
||||||
|
return MaterializeResult{}, fmt.Errorf("processSource %s: %w", t.SourceFileRelPath, err)
|
||||||
|
}
|
||||||
|
sources = append(sources, sr)
|
||||||
|
}
|
||||||
|
|
||||||
|
totals := Totals{}
|
||||||
|
for _, s := range sources {
|
||||||
|
totals.RowsRead += s.RowsRead
|
||||||
|
totals.RowsWritten += s.RowsWritten
|
||||||
|
totals.RowsSkipped += s.RowsSkipped
|
||||||
|
totals.RowsDeduped += s.RowsDeduped
|
||||||
|
}
|
||||||
|
|
||||||
|
endedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
durationMs := time.Now().UnixMilli() - startedMs
|
||||||
|
|
||||||
|
inputFiles := make([]FileReference, 0)
|
||||||
|
for _, s := range sources {
|
||||||
|
if !s.RowsPresent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(opts.Root, s.SourceFileRelPath)
|
||||||
|
ref, err := fileReferenceAt(path, s.SourceFileRelPath)
|
||||||
|
if err == nil {
|
||||||
|
inputFiles = append(inputFiles, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputFiles := make([]FileReference, 0)
|
||||||
|
for _, s := range sources {
|
||||||
|
for _, p := range s.OutputFiles {
|
||||||
|
rel := strings.TrimPrefix(p, opts.Root+string(os.PathSeparator))
|
||||||
|
ref, err := fileReferenceAt(p, rel)
|
||||||
|
if err == nil {
|
||||||
|
outputFiles = append(outputFiles, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errs []string
|
||||||
|
warnings []string
|
||||||
|
)
|
||||||
|
for _, s := range sources {
|
||||||
|
if !s.RowsPresent {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s: source file not found (skipped)", s.SourceFileRelPath))
|
||||||
|
}
|
||||||
|
if s.RowsSkipped > 0 {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s: %d rows skipped (validation/parse errors)", s.SourceFileRelPath, s.RowsSkipped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt := Receipt{
|
||||||
|
SchemaVersion: ReceiptSchemaVersion,
|
||||||
|
Command: commandLineOf(opts),
|
||||||
|
GitSHA: getGitSHA(opts.Root),
|
||||||
|
GitBranch: getGitBranch(opts.Root),
|
||||||
|
GitDirty: getGitDirty(opts.Root),
|
||||||
|
StartedAt: opts.RecordedAt,
|
||||||
|
EndedAt: endedAt,
|
||||||
|
DurationMs: durationMs,
|
||||||
|
InputFiles: inputFiles,
|
||||||
|
OutputFiles: outputFiles,
|
||||||
|
RecordCounts: RecordCounts{
|
||||||
|
In: totals.RowsRead,
|
||||||
|
Out: totals.RowsWritten,
|
||||||
|
Skipped: totals.RowsSkipped,
|
||||||
|
Deduped: totals.RowsDeduped,
|
||||||
|
},
|
||||||
|
ValidationPass: totals.RowsSkipped == 0,
|
||||||
|
Errors: emptyToNil(errs),
|
||||||
|
Warnings: emptyToNil(warnings),
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := strings.NewReplacer(":", "-", ".", "-").Replace(endedAt)
|
||||||
|
receiptDir := filepath.Join(reportsDir, stamp)
|
||||||
|
receiptPath := filepath.Join(receiptDir, "receipt.json")
|
||||||
|
if !opts.DryRun {
|
||||||
|
if err := os.MkdirAll(receiptDir, 0o755); err != nil {
|
||||||
|
return MaterializeResult{}, fmt.Errorf("mkdir receipt dir: %w", err)
|
||||||
|
}
|
||||||
|
buf, err := json.MarshalIndent(receipt, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return MaterializeResult{}, fmt.Errorf("marshal receipt: %w", err)
|
||||||
|
}
|
||||||
|
buf = append(buf, '\n')
|
||||||
|
if err := os.WriteFile(receiptPath, buf, 0o644); err != nil {
|
||||||
|
return MaterializeResult{}, fmt.Errorf("write receipt: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MaterializeResult{
|
||||||
|
Sources: sources,
|
||||||
|
Totals: totals,
|
||||||
|
Receipt: receipt,
|
||||||
|
ReceiptPath: receiptPath,
|
||||||
|
EvidenceDir: evidenceDir,
|
||||||
|
SkipsPath: skipsPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processSource reads, transforms, validates, and writes a single
|
||||||
|
// source JSONL.
|
||||||
|
func processSource(t TransformDef, opts MaterializeOptions, evidenceDir, skipsPath string) (SourceResult, error) {
|
||||||
|
srcPath := filepath.Join(opts.Root, t.SourceFileRelPath)
|
||||||
|
res := SourceResult{SourceFileRelPath: t.SourceFileRelPath}
|
||||||
|
|
||||||
|
info, err := os.Stat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
return res, fmt.Errorf("stat %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return res, fmt.Errorf("%s is a directory, not a file", srcPath)
|
||||||
|
}
|
||||||
|
res.RowsPresent = true
|
||||||
|
|
||||||
|
partition := isoDatePartition(opts.RecordedAt)
|
||||||
|
stem := stemFor(t.SourceFileRelPath)
|
||||||
|
outDir := filepath.Join(evidenceDir, partition)
|
||||||
|
outPath := filepath.Join(outDir, stem+".jsonl")
|
||||||
|
if !opts.DryRun {
|
||||||
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||||
|
return res, fmt.Errorf("mkdir output dir: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen, err := loadSeenHashes(outPath)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("load seen hashes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("open %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
rowsToWrite []byte
|
||||||
|
skipsToWrite []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 0, 1<<16), 1<<24)
|
||||||
|
lineOffset := int64(-1)
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineOffset++
|
||||||
|
raw := scanner.Bytes()
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.RowsRead++
|
||||||
|
|
||||||
|
var row map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &row); err != nil {
|
||||||
|
res.RowsSkipped++
|
||||||
|
skipsToWrite = appendSkip(skipsToWrite, SkipRecord{
|
||||||
|
SourceFile: t.SourceFileRelPath,
|
||||||
|
LineOffset: lineOffset,
|
||||||
|
Errors: []string{"JSON.parse failed: " + trim(err.Error(), 200)},
|
||||||
|
RecordedAt: opts.RecordedAt,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sigHash, err := CanonicalSha256(row)
|
||||||
|
if err != nil {
|
||||||
|
res.RowsSkipped++
|
||||||
|
skipsToWrite = appendSkip(skipsToWrite, SkipRecord{
|
||||||
|
SourceFile: t.SourceFileRelPath,
|
||||||
|
LineOffset: lineOffset,
|
||||||
|
Errors: []string{"sig_hash compute failed: " + trim(err.Error(), 200)},
|
||||||
|
RecordedAt: opts.RecordedAt,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seen[sigHash]; dup {
|
||||||
|
res.RowsDeduped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[sigHash] = struct{}{}
|
||||||
|
|
||||||
|
rec := t.Transform(TransformInput{
|
||||||
|
Row: row,
|
||||||
|
LineOffset: lineOffset,
|
||||||
|
SourceFileRelPath: t.SourceFileRelPath,
|
||||||
|
RecordedAt: opts.RecordedAt,
|
||||||
|
SigHash: sigHash,
|
||||||
|
})
|
||||||
|
if rec == nil {
|
||||||
|
res.RowsSkipped++
|
||||||
|
skipsToWrite = appendSkip(skipsToWrite, SkipRecord{
|
||||||
|
SourceFile: t.SourceFileRelPath,
|
||||||
|
LineOffset: lineOffset,
|
||||||
|
Errors: []string{"transform returned nil"},
|
||||||
|
SigHash: sigHash,
|
||||||
|
RecordedAt: opts.RecordedAt,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if vErrs := ValidateEvidenceRecord(*rec); len(vErrs) > 0 {
|
||||||
|
res.RowsSkipped++
|
||||||
|
skipsToWrite = appendSkip(skipsToWrite, SkipRecord{
|
||||||
|
SourceFile: t.SourceFileRelPath,
|
||||||
|
LineOffset: lineOffset,
|
||||||
|
Errors: vErrs,
|
||||||
|
SigHash: sigHash,
|
||||||
|
RecordedAt: opts.RecordedAt,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
res.RowsSkipped++
|
||||||
|
skipsToWrite = appendSkip(skipsToWrite, SkipRecord{
|
||||||
|
SourceFile: t.SourceFileRelPath,
|
||||||
|
LineOffset: lineOffset,
|
||||||
|
Errors: []string{"marshal output: " + trim(err.Error(), 200)},
|
||||||
|
SigHash: sigHash,
|
||||||
|
RecordedAt: opts.RecordedAt,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rowsToWrite = append(rowsToWrite, buf...)
|
||||||
|
rowsToWrite = append(rowsToWrite, '\n')
|
||||||
|
res.RowsWritten++
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return res, fmt.Errorf("scan %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.DryRun {
|
||||||
|
if len(rowsToWrite) > 0 {
|
||||||
|
if err := appendBytes(outPath, rowsToWrite); err != nil {
|
||||||
|
return res, fmt.Errorf("append output: %w", err)
|
||||||
|
}
|
||||||
|
res.OutputFiles = append(res.OutputFiles, outPath)
|
||||||
|
}
|
||||||
|
if len(skipsToWrite) > 0 {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(skipsPath), 0o755); err != nil {
|
||||||
|
return res, fmt.Errorf("mkdir skips dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := appendBytes(skipsPath, skipsToWrite); err != nil {
|
||||||
|
return res, fmt.Errorf("append skips: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSeenHashes reads sig_hashes from an existing day-partition output
|
||||||
|
// file. Idempotency: a re-run that produces the same hash is a dedup
|
||||||
|
// not a duplicate write.
|
||||||
|
func loadSeenHashes(outPath string) (map[string]struct{}, error) {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
f, err := os.Open(outPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return seen, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 0, 1<<16), 1<<24)
|
||||||
|
for scanner.Scan() {
|
||||||
|
raw := scanner.Bytes()
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec struct {
|
||||||
|
Provenance struct {
|
||||||
|
SigHash string `json:"sig_hash"`
|
||||||
|
} `json:"provenance"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &rec); err != nil {
|
||||||
|
continue // malformed line; ignore
|
||||||
|
}
|
||||||
|
if rec.Provenance.SigHash != "" {
|
||||||
|
seen[rec.Provenance.SigHash] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendSkip(buf []byte, sk SkipRecord) []byte {
|
||||||
|
out, err := json.Marshal(sk)
|
||||||
|
if err != nil {
|
||||||
|
// Should never happen for the well-typed SkipRecord — fall back
|
||||||
|
// to a sentinel so the materializer doesn't drop the skip silently.
|
||||||
|
return append(buf, []byte(fmt.Sprintf(`{"source_file":%q,"line_offset":%d,"errors":["marshal_skip_failed:%s"],"recorded_at":%q}`+"\n",
|
||||||
|
sk.SourceFile, sk.LineOffset, err.Error(), sk.RecordedAt))...)
|
||||||
|
}
|
||||||
|
buf = append(buf, out...)
|
||||||
|
buf = append(buf, '\n')
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendBytes(path string, data []byte) error {
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = f.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isoDatePartition(iso string) string {
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, iso)
|
||||||
|
if err != nil {
|
||||||
|
t, err = time.Parse(time.RFC3339, iso)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Defense-in-depth only — MaterializeAll calls validISOTimestamp
|
||||||
|
// on RecordedAt before processSource ever invokes us, so this
|
||||||
|
// branch is unreachable through the public surface. Kept so a
|
||||||
|
// future direct caller doesn't get a panic; "0000/00/00" is at
|
||||||
|
// least a valid path.
|
||||||
|
return "0000/00/00"
|
||||||
|
}
|
||||||
|
t = t.UTC()
|
||||||
|
return fmt.Sprintf("%04d/%02d/%02d", t.Year(), int(t.Month()), t.Day())
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileReferenceAt(path, relpath string) (FileReference, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return FileReference{}, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
hasher := sha256.New()
|
||||||
|
n, err := io.Copy(hasher, f)
|
||||||
|
if err != nil {
|
||||||
|
return FileReference{}, err
|
||||||
|
}
|
||||||
|
return FileReference{
|
||||||
|
Path: relpath,
|
||||||
|
SHA256: hex.EncodeToString(hasher.Sum(nil)),
|
||||||
|
Bytes: n,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitSHA(root string) string {
|
||||||
|
out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
|
||||||
|
if err != nil {
|
||||||
|
return strings.Repeat("0", 40)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitBranch(root string) string {
|
||||||
|
out, err := exec.Command("git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitDirty(root string) bool {
|
||||||
|
out, err := exec.Command("git", "-C", root, "status", "--porcelain").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandLineOf(opts MaterializeOptions) string {
|
||||||
|
cmd := "go run ./cmd/materializer"
|
||||||
|
if opts.DryRun {
|
||||||
|
cmd += " --dry-run"
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyToNil(s []string) []string {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package materializer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMaterializeAll_RoundTrip writes a fixture source jsonl, runs the
|
||||||
|
// materializer, and checks every contract: receipt, output rows,
|
||||||
|
// idempotency on second run.
|
||||||
|
func TestMaterializeAll_RoundTrip(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustWriteFixture(t, root, "data/_kb/distilled_facts.jsonl",
|
||||||
|
`{"run_id":"r1","source_label":"lab-a","created_at":"2026-04-26T00:00:00Z","extractor":"qwen3.5:latest","text":"first"}
|
||||||
|
{"run_id":"r2","source_label":"lab-b","created_at":"2026-04-26T01:00:00Z","extractor":"qwen3.5:latest","text":"second"}`)
|
||||||
|
|
||||||
|
transforms := []TransformDef{
|
||||||
|
{SourceFileRelPath: "data/_kb/distilled_facts.jsonl", Transform: extractorTransform},
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := MaterializeAll(MaterializeOptions{
|
||||||
|
Root: root,
|
||||||
|
Transforms: transforms,
|
||||||
|
RecordedAt: "2026-05-02T00:00:00Z",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first run: %v", err)
|
||||||
|
}
|
||||||
|
if !first.Receipt.ValidationPass {
|
||||||
|
t.Errorf("first run should pass validation. errors=%v warnings=%v", first.Receipt.Errors, first.Receipt.Warnings)
|
||||||
|
}
|
||||||
|
if first.Totals.RowsRead != 2 || first.Totals.RowsWritten != 2 || first.Totals.RowsSkipped != 0 {
|
||||||
|
t.Errorf("first run counts wrong: %+v", first.Totals)
|
||||||
|
}
|
||||||
|
if first.Totals.RowsDeduped != 0 {
|
||||||
|
t.Errorf("first run should have 0 dedupes, got %d", first.Totals.RowsDeduped)
|
||||||
|
}
|
||||||
|
|
||||||
|
outPath := filepath.Join(root, "data/evidence/2026/05/02/distilled_facts.jsonl")
|
||||||
|
rows := readJSONL(t, outPath)
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 output rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r["schema_version"].(float64) != 1 {
|
||||||
|
t.Errorf("schema_version wrong: %v", r["schema_version"])
|
||||||
|
}
|
||||||
|
prov := r["provenance"].(map[string]any)
|
||||||
|
if prov["source_file"] != "data/_kb/distilled_facts.jsonl" {
|
||||||
|
t.Errorf("provenance.source_file: %v", prov["source_file"])
|
||||||
|
}
|
||||||
|
if prov["recorded_at"] != "2026-05-02T00:00:00Z" {
|
||||||
|
t.Errorf("provenance.recorded_at: %v", prov["recorded_at"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second run with identical input + RecordedAt → all rows should
|
||||||
|
// dedup, nothing newly written.
|
||||||
|
second, err := MaterializeAll(MaterializeOptions{
|
||||||
|
Root: root,
|
||||||
|
Transforms: transforms,
|
||||||
|
RecordedAt: "2026-05-02T00:00:00Z",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second run: %v", err)
|
||||||
|
}
|
||||||
|
if second.Totals.RowsRead != 2 || second.Totals.RowsWritten != 0 || second.Totals.RowsDeduped != 2 {
|
||||||
|
t.Errorf("idempotency broken; second run counts: %+v", second.Totals)
|
||||||
|
}
|
||||||
|
rows2 := readJSONL(t, outPath)
|
||||||
|
if len(rows2) != 2 {
|
||||||
|
t.Fatalf("output file grew on idempotent rerun: %d rows", len(rows2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaterializeAll_BadJSONLineGoesToSkips(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustWriteFixture(t, root, "data/_kb/distilled_facts.jsonl",
|
||||||
|
`{"run_id":"r1","source_label":"a","created_at":"2026-04-26T00:00:00Z","extractor":"q","text":"t"}
|
||||||
|
not-json
|
||||||
|
{"run_id":"r2","source_label":"b","created_at":"2026-04-26T01:00:00Z","extractor":"q","text":"t2"}`)
|
||||||
|
|
||||||
|
transforms := []TransformDef{
|
||||||
|
{SourceFileRelPath: "data/_kb/distilled_facts.jsonl", Transform: extractorTransform},
|
||||||
|
}
|
||||||
|
res, err := MaterializeAll(MaterializeOptions{
|
||||||
|
Root: root,
|
||||||
|
Transforms: transforms,
|
||||||
|
RecordedAt: "2026-05-02T00:00:00Z",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
if res.Totals.RowsWritten != 2 {
|
||||||
|
t.Errorf("good rows should still pass through; written=%d", res.Totals.RowsWritten)
|
||||||
|
}
|
||||||
|
if res.Totals.RowsSkipped != 1 {
|
||||||
|
t.Errorf("bad-json row should be in skipped bucket; got %d", res.Totals.RowsSkipped)
|
||||||
|
}
|
||||||
|
if res.Receipt.ValidationPass {
|
||||||
|
t.Errorf("validation_pass should be false when any row was skipped")
|
||||||
|
}
|
||||||
|
|
||||||
|
skipsPath := filepath.Join(root, "data/_kb/distillation_skips.jsonl")
|
||||||
|
skips := readJSONL(t, skipsPath)
|
||||||
|
if len(skips) != 1 {
|
||||||
|
t.Fatalf("expected 1 skip record, got %d", len(skips))
|
||||||
|
}
|
||||||
|
if !strings.Contains(toJSON(t, skips[0]), "JSON.parse failed") {
|
||||||
|
t.Errorf("skip record should mention parse failure: %v", skips[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaterializeAll_DryRunWritesNothing(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustWriteFixture(t, root, "data/_kb/distilled_facts.jsonl",
|
||||||
|
`{"run_id":"r1","source_label":"a","created_at":"2026-04-26T00:00:00Z","extractor":"q","text":"t"}`)
|
||||||
|
|
||||||
|
transforms := []TransformDef{
|
||||||
|
{SourceFileRelPath: "data/_kb/distilled_facts.jsonl", Transform: extractorTransform},
|
||||||
|
}
|
||||||
|
res, err := MaterializeAll(MaterializeOptions{
|
||||||
|
Root: root,
|
||||||
|
Transforms: transforms,
|
||||||
|
RecordedAt: "2026-05-02T00:00:00Z",
|
||||||
|
DryRun: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dry run: %v", err)
|
||||||
|
}
|
||||||
|
if res.Totals.RowsRead != 1 || res.Totals.RowsWritten != 1 {
|
||||||
|
t.Errorf("dry run should still count, got %+v", res.Totals)
|
||||||
|
}
|
||||||
|
outPath := filepath.Join(root, "data/evidence/2026/05/02/distilled_facts.jsonl")
|
||||||
|
if _, err := os.Stat(outPath); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("dry run wrote output file (should not): err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(res.ReceiptPath); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("dry run wrote receipt (should not): err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaterializeAll_MissingSourceTalliedAsWarning(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
transforms := []TransformDef{
|
||||||
|
{SourceFileRelPath: "data/_kb/distilled_facts.jsonl", Transform: extractorTransform},
|
||||||
|
}
|
||||||
|
res, err := MaterializeAll(MaterializeOptions{
|
||||||
|
Root: root,
|
||||||
|
Transforms: transforms,
|
||||||
|
RecordedAt: "2026-05-02T00:00:00Z",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
if res.Sources[0].RowsPresent {
|
||||||
|
t.Errorf("expected rows_present=false")
|
||||||
|
}
|
||||||
|
if !res.Receipt.ValidationPass {
|
||||||
|
t.Errorf("missing source ≠ validation failure; got pass=%v warnings=%v", res.Receipt.ValidationPass, res.Receipt.Warnings)
|
||||||
|
}
|
||||||
|
if len(res.Receipt.Warnings) == 0 {
|
||||||
|
t.Errorf("missing source should produce a warning")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func mustWriteFixture(t *testing.T, root, relpath, content string) {
|
||||||
|
t.Helper()
|
||||||
|
full := filepath.Join(root, relpath)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSONL(t *testing.T, path string) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []map[string]any
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 0, 1<<16), 1<<24)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var row map[string]any
|
||||||
|
if err := json.Unmarshal(line, &row); err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", path, err)
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
t.Fatalf("scan %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toJSON(t *testing.T, v any) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
package materializer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/distillation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransformInput is what each TransformFn receives. Mirrors the TS
|
||||||
|
// TransformInput shape — every field is supplied by the materializer
|
||||||
|
// driver, not by the transform.
|
||||||
|
type TransformInput struct {
|
||||||
|
Row map[string]any
|
||||||
|
LineOffset int64
|
||||||
|
SourceFileRelPath string // relative to repo root
|
||||||
|
RecordedAt string // ISO 8601, caller's "now"
|
||||||
|
SigHash string // canonical sha256 of row, pre-computed
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransformFn maps a single source row to an EvidenceRecord. Returning
|
||||||
|
// nil signals "skip this row" — the materializer logs a deterministic
|
||||||
|
// skip with no record produced.
|
||||||
|
//
|
||||||
|
// Transforms must be pure: no I/O, no clock reads, no model calls.
|
||||||
|
// Any time component must come from the row itself or RecordedAt.
|
||||||
|
type TransformFn func(in TransformInput) *distillation.EvidenceRecord
|
||||||
|
|
||||||
|
// TransformDef binds a source-file path to its TransformFn. Order in
|
||||||
|
// Transforms[] has no effect (each runs against its own SourceFile).
|
||||||
|
type TransformDef struct {
|
||||||
|
SourceFileRelPath string
|
||||||
|
Transform TransformFn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transforms — one per source-file. Ports of TRANSFORMS[] in
|
||||||
|
// scripts/distillation/transforms.ts. Tier 1 first (validated), Tier 2
|
||||||
|
// second (untested but in-shape). ────────────────────────────────────
|
||||||
|
|
||||||
|
// Transforms is the canonical list. CLI passes this to MaterializeAll.
|
||||||
|
// Adding a new source: append a TransformDef.
|
||||||
|
var Transforms = []TransformDef{
|
||||||
|
// ── Tier 1: validated 100% in Phase 1 ─────────────────────────
|
||||||
|
{SourceFileRelPath: "data/_kb/distilled_facts.jsonl", Transform: extractorTransform},
|
||||||
|
{SourceFileRelPath: "data/_kb/distilled_procedures.jsonl", Transform: extractorTransform},
|
||||||
|
{SourceFileRelPath: "data/_kb/distilled_config_hints.jsonl", Transform: extractorTransform},
|
||||||
|
{SourceFileRelPath: "data/_kb/contract_analyses.jsonl", Transform: contractAnalysesTransform},
|
||||||
|
{SourceFileRelPath: "data/_kb/mode_experiments.jsonl", Transform: modeExperimentsTransform},
|
||||||
|
{SourceFileRelPath: "data/_kb/scrum_reviews.jsonl", Transform: scrumReviewsTransform},
|
||||||
|
{SourceFileRelPath: "data/_kb/observer_escalations.jsonl", Transform: observerEscalationsTransform},
|
||||||
|
{SourceFileRelPath: "data/_kb/audit_facts.jsonl", Transform: auditFactsTransform},
|
||||||
|
|
||||||
|
// ── Tier 2: untested streams that still belong in EvidenceRecord ──
|
||||||
|
{SourceFileRelPath: "data/_kb/auto_apply.jsonl", Transform: autoApplyTransform},
|
||||||
|
{SourceFileRelPath: "data/_kb/observer_reviews.jsonl", Transform: observerReviewsTransform},
|
||||||
|
{SourceFileRelPath: "data/_kb/audits.jsonl", Transform: auditsTransform},
|
||||||
|
{SourceFileRelPath: "data/_kb/outcomes.jsonl", Transform: outcomesTransform},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransformByPath returns the TransformDef for a given source path,
|
||||||
|
// or nil if no transform is registered. Matches the TS helper.
|
||||||
|
func TransformByPath(relpath string) *TransformDef {
|
||||||
|
for i := range Transforms {
|
||||||
|
if Transforms[i].SourceFileRelPath == relpath {
|
||||||
|
return &Transforms[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Per-source transform implementations ─────────────────────────
|
||||||
|
|
||||||
|
// extractorTransform powers the three distilled_* sources. Same shape:
|
||||||
|
// LLM-extracted text with a model_name from `extractor`.
|
||||||
|
func extractorTransform(in TransformInput) *distillation.EvidenceRecord {
|
||||||
|
stem := stemFor(in.SourceFileRelPath)
|
||||||
|
rec := distillation.EvidenceRecord{
|
||||||
|
RunID: strDefault(in.Row, "run_id", fmt.Sprintf("%s:%d", stem, in.LineOffset)),
|
||||||
|
TaskID: strDefault(in.Row, "source_label", fmt.Sprintf("%s:%d", stem, in.LineOffset)),
|
||||||
|
Timestamp: getString(in.Row, "created_at"),
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: provenance(in),
|
||||||
|
ModelName: getString(in.Row, "extractor"),
|
||||||
|
ModelRole: distillation.RoleExtractor,
|
||||||
|
ModelProvider: "ollama",
|
||||||
|
Text: getString(in.Row, "text"),
|
||||||
|
}
|
||||||
|
return &rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// contractAnalysesTransform: per-permit executor with observer signals,
|
||||||
|
// retrieval telemetry, and cost in micro-units that gets converted to
|
||||||
|
// USD. Carries `contractor` in metadata.
|
||||||
|
func contractAnalysesTransform(in TransformInput) *distillation.EvidenceRecord {
|
||||||
|
permitID := getString(in.Row, "permit_id")
|
||||||
|
tsStr := getString(in.Row, "ts")
|
||||||
|
tsMs := timeToMS(tsStr)
|
||||||
|
|
||||||
|
rec := distillation.EvidenceRecord{
|
||||||
|
RunID: fmt.Sprintf("contract_analysis:%s:%d", permitID, tsMs),
|
||||||
|
TaskID: fmt.Sprintf("permit:%s", permitID),
|
||||||
|
Timestamp: tsStr,
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: provenance(in),
|
||||||
|
ModelRole: distillation.RoleExecutor,
|
||||||
|
Text: getString(in.Row, "analysis"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc := buildRetrievedContext(map[string]any{
|
||||||
|
"matrix_corpora": objectKeys(in.Row, "matrix_corpora"),
|
||||||
|
"matrix_hits": in.Row["matrix_hits"],
|
||||||
|
}); rc != nil {
|
||||||
|
rec.RetrievedContext = rc
|
||||||
|
}
|
||||||
|
|
||||||
|
if notes := flattenNotes(in.Row, "observer_notes"); len(notes) > 0 {
|
||||||
|
rec.ObserverNotes = notes
|
||||||
|
}
|
||||||
|
if v, ok := in.Row["observer_verdict"].(string); ok && v != "" {
|
||||||
|
rec.ObserverVerdict = distillation.ObserverVerdict(v)
|
||||||
|
}
|
||||||
|
if c, ok := numFloat(in.Row, "observer_conf"); ok {
|
||||||
|
rec.ObserverConfidence = c
|
||||||
|
}
|
||||||
|
if ok, present := boolField(in.Row, "ok"); present && ok {
|
||||||
|
rec.SuccessMarkers = []string{"matrix_hits_above_threshold"}
|
||||||
|
}
|
||||||
|
verdict := getString(in.Row, "observer_verdict")
|
||||||
|
okPresent, _ := boolField(in.Row, "ok")
|
||||||
|
if !okPresent || verdict == "reject" {
|
||||||
|
rec.FailureMarkers = []string{"observer_rejected"}
|
||||||
|
}
|
||||||
|
if cost, ok := numFloat(in.Row, "cost"); ok {
|
||||||
|
rec.CostUSD = cost / 1_000_000.0
|
||||||
|
}
|
||||||
|
if d, ok := numInt(in.Row, "duration_ms"); ok {
|
||||||
|
rec.LatencyMs = d
|
||||||
|
}
|
||||||
|
if contractor := getString(in.Row, "contractor"); contractor != "" {
|
||||||
|
rec.Metadata = map[string]any{"contractor": contractor}
|
||||||
|
}
|
||||||
|
return &rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeExperimentsTransform: mode_runner per-call traces. Provider
|
||||||
|
// derived from model name shape ("/" → openrouter, else ollama_cloud).
|
||||||
|
func modeExperimentsTransform(in TransformInput) *distillation.EvidenceRecord {
|
||||||
|
tsStr := getString(in.Row, "ts")
|
||||||
|
tsMs := timeToMS(tsStr)
|
||||||
|
filePath := getString(in.Row, "file_path")
|
||||||
|
keySuffix := filePath
|
||||||
|
if keySuffix == "" {
|
||||||
|
keySuffix = fmt.Sprintf("%d", in.LineOffset)
|
||||||
|
}
|
||||||
|
model := getString(in.Row, "model")
|
||||||
|
provider := "ollama_cloud"
|
||||||
|
if strings.Contains(model, "/") {
|
||||||
|
provider = "openrouter"
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := distillation.EvidenceRecord{
|
||||||
|
RunID: fmt.Sprintf("mode_exec:%d:%s", tsMs, keySuffix),
|
||||||
|
TaskID: getString(in.Row, "task_class"),
|
||||||
|
Timestamp: tsStr,
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: provenance(in),
|
||||||
|
ModelName: model,
|
||||||
|
ModelRole: distillation.RoleExecutor,
|
||||||
|
ModelProvider: provider,
|
||||||
|
Text: getString(in.Row, "response"),
|
||||||
|
}
|
||||||
|
if d, ok := numInt(in.Row, "latency_ms"); ok {
|
||||||
|
rec.LatencyMs = d
|
||||||
|
}
|
||||||
|
if filePath != "" {
|
||||||
|
rec.SourceFiles = []string{filePath}
|
||||||
|
}
|
||||||
|
if sources, ok := in.Row["sources"].(map[string]any); ok {
|
||||||
|
rec.RetrievedContext = buildRetrievedContext(map[string]any{
|
||||||
|
"matrix_corpora": sources["matrix_corpus"],
|
||||||
|
"matrix_chunks_kept": sources["matrix_chunks_kept"],
|
||||||
|
"matrix_chunks_dropped": sources["matrix_chunks_dropped"],
|
||||||
|
"pathway_fingerprints_seen": sources["bug_fingerprints_count"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrumReviewsTransform: per-file scrum review traces. Success marker
|
||||||
|
// captures the attempt number when accepted.
|
||||||
|
func scrumReviewsTransform(in TransformInput) *distillation.EvidenceRecord {
|
||||||
|
reviewedAt := getString(in.Row, "reviewed_at")
|
||||||
|
tsMs := timeToMS(reviewedAt)
|
||||||
|
file := getString(in.Row, "file")
|
||||||
|
rec := distillation.EvidenceRecord{
|
||||||
|
RunID: fmt.Sprintf("scrum:%d:%s", tsMs, file),
|
||||||
|
TaskID: fmt.Sprintf("scrum_review:%s", file),
|
||||||
|
Timestamp: reviewedAt,
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: provenance(in),
|
||||||
|
ModelName: getString(in.Row, "accepted_model"),
|
||||||
|
ModelRole: distillation.RoleExecutor,
|
||||||
|
Text: getString(in.Row, "suggestions_preview"),
|
||||||
|
}
|
||||||
|
if file != "" {
|
||||||
|
rec.SourceFiles = []string{file}
|
||||||
|
}
|
||||||
|
if a, ok := numInt(in.Row, "accepted_on_attempt"); ok && a > 0 {
|
||||||
|
rec.SuccessMarkers = []string{fmt.Sprintf("accepted_on_attempt_%d", a)}
|
||||||
|
}
|
||||||
|
return &rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// observerEscalationsTransform: reviewer-class trace; carries token
|
||||||
|
// counts so the SFT exporter sees real usage signals.
|
||||||
|
func observerEscalationsTransform(in TransformInput) *distillation.EvidenceRecord {
|
||||||
|
tsStr := getString(in.Row, "ts")
|
||||||
|
tsMs := timeToMS(tsStr)
|
||||||
|
rec := distillation.EvidenceRecord{
|
||||||
|
RunID: fmt.Sprintf("obs_esc:%d:%s", tsMs, getString(in.Row, "sig_hash")),
|
||||||
|
TaskID: fmt.Sprintf("observer_escalation:%s", strDefault(in.Row, "cluster_endpoint", "?")),
|
||||||
|
Timestamp: tsStr,
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: provenance(in),
|
||||||
|
ModelRole: distillation.RoleReviewer,
|
||||||
|
Text: getString(in.Row, "analysis"),
|
||||||
|
}
|
||||||
|
if pt, ok := numInt(in.Row, "prompt_tokens"); ok {
|
||||||
|
rec.PromptTokens = pt
|
||||||
|
}
|
||||||
|
if ct, ok := numInt(in.Row, "completion_tokens"); ok {
|
||||||
|
rec.CompletionTokens = ct
|
||||||
|
}
|
||||||
|
return &rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditFactsTransform: per-PR auditor extraction. Text is a compact
|
||||||
|
// JSON summary of array lengths (facts/entities/relationships).
|
||||||
|
func auditFactsTransform(in TransformInput) *distillation.EvidenceRecord {
|
||||||
|
headSHA := getString(in.Row, "head_sha")
|
||||||
|
prNumber := getString(in.Row, "pr_number")
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"facts": arrayLen(in.Row, "facts"),
|
||||||
|
"entities": arrayLen(in.Row, "entities"),
|
||||||
|
"relationships": arrayLen(in.Row, "relationships"),
|
||||||
|
})
|
||||||
|
rec := distillation.EvidenceRecord{
|
||||||
|
RunID: fmt.Sprintf("audit_facts:%s:%d", headSHA, in.LineOffset),
|
||||||
|
TaskID: fmt.Sprintf("pr:%s", prNumber),
|
||||||
|
Timestamp: getString(in.Row, "extracted_at"),
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: provenance(in),
|
||||||
|
ModelName: getString(in.Row, "extractor"),
|
||||||
|
ModelRole: distillation.RoleExtractor,
|
||||||
|
Text: string(body),
|
||||||
|
}
|
||||||
|
return &rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoApplyTransform: applier traces. Pure metadata — no text payload.
|
||||||
|
// Deterministic ts fallback to RecordedAt when the row lacks one
|
||||||
|
// (matches TS comment about wall-clock leak fix).
|
||||||
|
func autoApplyTransform(in TransformInput) *distillation.EvidenceRecord {
|
||||||
|
ts := getString(in.Row, "ts")
|
||||||
|
if ts == "" {
|
||||||
|
ts = in.RecordedAt
|
||||||
|
}
|
||||||
|
tsMs := timeToMS(ts)
|
||||||
|
action := strDefault(in.Row, "action", "unknown")
|
||||||
|
file := getString(in.Row, "file")
|
||||||
|
keySuffix := file
|
||||||
|
if keySuffix == "" {
|
||||||
|
keySuffix = fmt.Sprintf("%d", in.LineOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := distillation.EvidenceRecord{
|
||||||
|
RunID: fmt.Sprintf("auto_apply:%d:%s", tsMs, keySuffix),
|
||||||
|
TaskID: fmt.Sprintf("auto_apply:%s", strDefault(in.Row, "file", "?")),
|
||||||
|
Timestamp: ts,
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: provenance(in),
|
||||||
|
ModelRole: distillation.RoleApplier,
|
||||||
|
}
|
||||||
|
if file != "" {
|
||||||
|
rec.SourceFiles = []string{file}
|
||||||
|
}
|
||||||
|
if action == "committed" {
|
||||||
|
rec.SuccessMarkers = []string{"committed"}
|
||||||
|
}
|
||||||
|
if strings.Contains(action, "reverted") {
|
||||||
|
rec.FailureMarkers = []string{action}
|
||||||
|
}
|
||||||
|
return &rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// observerReviewsTransform: reviewer-class. Falls back from `ts` to
|
||||||
|
// `reviewed_at`. Mirrors observer_escalations but carries verdict +
|
||||||
|
// confidence + free-form notes.
|
||||||
|
func observerReviewsTransform(in TransformInput) *distillation.EvidenceRecord {
|
||||||
|
ts := getString(in.Row, "ts")
|
||||||
|
if ts == "" {
|
||||||
|
ts = getString(in.Row, "reviewed_at")
|
||||||
|
}
|
||||||
|
tsMs := timeToMS(ts)
|
||||||
|
file := getString(in.Row, "file")
|
||||||
|
|
||||||
|
keySuffix := file
|
||||||
|
if keySuffix == "" {
|
||||||
|
keySuffix = fmt.Sprintf("%d", in.LineOffset)
|
||||||
|
}
|
||||||
|
taskID := fmt.Sprintf("observer_review:%s", keySuffix)
|
||||||
|
if file == "" {
|
||||||
|
taskID = fmt.Sprintf("observer_review:%d", in.LineOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := distillation.EvidenceRecord{
|
||||||
|
RunID: fmt.Sprintf("obs_rev:%d:%s", tsMs, keySuffix),
|
||||||
|
TaskID: taskID,
|
||||||
|
Timestamp: ts,
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: provenance(in),
|
||||||
|
ModelRole: distillation.RoleReviewer,
|
||||||
|
}
|
||||||
|
if v, ok := in.Row["verdict"].(string); ok && v != "" {
|
||||||
|
rec.ObserverVerdict = distillation.ObserverVerdict(v)
|
||||||
|
}
|
||||||
|
if c, ok := numFloat(in.Row, "confidence"); ok {
|
||||||
|
rec.ObserverConfidence = c
|
||||||
|
}
|
||||||
|
if notes := flattenNotes(in.Row, "notes"); len(notes) > 0 {
|
||||||
|
rec.ObserverNotes = notes
|
||||||
|
}
|
||||||
|
if text := getString(in.Row, "notes"); text != "" {
|
||||||
|
rec.Text = text
|
||||||
|
} else if review := getString(in.Row, "review"); review != "" {
|
||||||
|
rec.Text = review
|
||||||
|
}
|
||||||
|
return &rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditsTransform: per-finding auditor stream. Severity drives the
|
||||||
|
// success/failure marker shape — info/low → success, medium →
|
||||||
|
// non-fatal failure, high/critical → blocking failure.
|
||||||
|
//
|
||||||
|
// Note on determinism: the TS port falls back to `new Date().toISOString()`
|
||||||
|
// when `ts` is missing, which is non-deterministic. The Go port uses
|
||||||
|
// RecordedAt as the deterministic fallback (matches the
|
||||||
|
// auto_apply fix pattern).
|
||||||
|
func auditsTransform(in TransformInput) *distillation.EvidenceRecord {
|
||||||
|
sev := strings.ToLower(strDefault(in.Row, "severity", "unknown"))
|
||||||
|
minor := sev == "info" || sev == "low"
|
||||||
|
blocking := sev == "high" || sev == "critical"
|
||||||
|
medium := sev == "medium"
|
||||||
|
|
||||||
|
findingID := getString(in.Row, "finding_id")
|
||||||
|
keySuffix := findingID
|
||||||
|
if keySuffix == "" {
|
||||||
|
keySuffix = fmt.Sprintf("%d", in.LineOffset)
|
||||||
|
}
|
||||||
|
phase := getString(in.Row, "phase")
|
||||||
|
taskID := "audit_finding"
|
||||||
|
if phase != "" {
|
||||||
|
taskID = fmt.Sprintf("phase:%s", phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := getString(in.Row, "ts")
|
||||||
|
if ts == "" {
|
||||||
|
ts = in.RecordedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := distillation.EvidenceRecord{
|
||||||
|
RunID: fmt.Sprintf("audit_finding:%s", keySuffix),
|
||||||
|
TaskID: taskID,
|
||||||
|
Timestamp: ts,
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: provenance(in),
|
||||||
|
ModelRole: distillation.RoleReviewer,
|
||||||
|
}
|
||||||
|
if minor {
|
||||||
|
rec.SuccessMarkers = []string{fmt.Sprintf("audit_severity_%s", sev)}
|
||||||
|
}
|
||||||
|
if blocking {
|
||||||
|
rec.FailureMarkers = []string{fmt.Sprintf("audit_severity_%s", sev)}
|
||||||
|
} else if medium {
|
||||||
|
rec.FailureMarkers = []string{"audit_severity_medium"}
|
||||||
|
}
|
||||||
|
if ev, ok := in.Row["evidence"].(string); ok && ev != "" {
|
||||||
|
rec.Text = ev
|
||||||
|
} else {
|
||||||
|
rec.Text = getString(in.Row, "resolution")
|
||||||
|
}
|
||||||
|
return &rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// outcomesTransform: command-runner outcome stream. Latency from
|
||||||
|
// elapsed_secs (× 1000), success when all events ok.
|
||||||
|
func outcomesTransform(in TransformInput) *distillation.EvidenceRecord {
|
||||||
|
rec := distillation.EvidenceRecord{
|
||||||
|
RunID: fmt.Sprintf("outcome:%s", strDefault(in.Row, "run_id", fmt.Sprintf("%d", in.LineOffset))),
|
||||||
|
Timestamp: getString(in.Row, "created_at"),
|
||||||
|
SchemaVersion: distillation.EvidenceSchemaVersion,
|
||||||
|
Provenance: provenance(in),
|
||||||
|
ModelRole: distillation.RoleExecutor,
|
||||||
|
}
|
||||||
|
if sigHash := getString(in.Row, "sig_hash"); sigHash != "" {
|
||||||
|
rec.TaskID = fmt.Sprintf("outcome_sig:%s", sigHash)
|
||||||
|
} else {
|
||||||
|
rec.TaskID = fmt.Sprintf("outcome:%d", in.LineOffset)
|
||||||
|
}
|
||||||
|
if elapsed, ok := numFloat(in.Row, "elapsed_secs"); ok {
|
||||||
|
rec.LatencyMs = int64(elapsed*1000 + 0.5) // rounded
|
||||||
|
}
|
||||||
|
if okEv, ok1 := numInt(in.Row, "ok_events"); ok1 {
|
||||||
|
if total, ok2 := numInt(in.Row, "total_events"); ok2 {
|
||||||
|
if total > 0 && okEv == total {
|
||||||
|
rec.SuccessMarkers = []string{"all_events_ok"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g, ok := numInt(in.Row, "total_gap_signals"); ok {
|
||||||
|
vr := map[string]any{"gap_signals": g}
|
||||||
|
if c, ok2 := numInt(in.Row, "total_citations"); ok2 {
|
||||||
|
vr["citation_count"] = c
|
||||||
|
}
|
||||||
|
rec.ValidationResults = vr
|
||||||
|
}
|
||||||
|
return &rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers — coercion + extraction patterns shared by transforms ──
|
||||||
|
|
||||||
|
func provenance(in TransformInput) distillation.Provenance {
|
||||||
|
return distillation.Provenance{
|
||||||
|
SourceFile: in.SourceFileRelPath,
|
||||||
|
LineOffset: in.LineOffset,
|
||||||
|
SigHash: in.SigHash,
|
||||||
|
RecordedAt: in.RecordedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stemFor extracts "distilled_facts" from "data/_kb/distilled_facts.jsonl".
|
||||||
|
func stemFor(relpath string) string {
|
||||||
|
idx := strings.LastIndex(relpath, "/")
|
||||||
|
base := relpath
|
||||||
|
if idx >= 0 {
|
||||||
|
base = relpath[idx+1:]
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(base, ".jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getString returns row[key] as a string, or "" if missing/wrong-type.
|
||||||
|
func getString(row map[string]any, key string) string {
|
||||||
|
v, ok := row[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case float64:
|
||||||
|
return fmt.Sprintf("%v", t)
|
||||||
|
case bool:
|
||||||
|
return fmt.Sprintf("%t", t)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// strDefault returns row[key] coerced to string, or fallback if empty/missing.
|
||||||
|
func strDefault(row map[string]any, key, fallback string) string {
|
||||||
|
if s := getString(row, key); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// numInt returns row[key] as int64. JSON numbers come in as float64.
|
||||||
|
// Returns (val, true) when present and finite, else (0, false).
|
||||||
|
func numInt(row map[string]any, key string) (int64, bool) {
|
||||||
|
v, ok := row[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(t), true
|
||||||
|
case int:
|
||||||
|
return int64(t), true
|
||||||
|
case int64:
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// numFloat returns row[key] as float64.
|
||||||
|
func numFloat(row map[string]any, key string) (float64, bool) {
|
||||||
|
v, ok := row[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return t, true
|
||||||
|
case int:
|
||||||
|
return float64(t), true
|
||||||
|
case int64:
|
||||||
|
return float64(t), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolField returns (value, present). present=false when key missing
|
||||||
|
// or non-bool.
|
||||||
|
func boolField(row map[string]any, key string) (bool, bool) {
|
||||||
|
v, ok := row[key]
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
if b, isBool := v.(bool); isBool {
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// arrayLen returns len(row[key]) if it's an array, else 0.
|
||||||
|
func arrayLen(row map[string]any, key string) int {
|
||||||
|
if a, ok := row[key].([]any); ok {
|
||||||
|
return len(a)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectKeys returns sorted keys of row[key] when it's a map. Returns
|
||||||
|
// nil when missing or non-map (so callers can treat empty corpus list
|
||||||
|
// as "field absent").
|
||||||
|
func objectKeys(row map[string]any, key string) []string {
|
||||||
|
m, ok := row[key].(map[string]any)
|
||||||
|
if !ok || len(m) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
// Sort for determinism — TS Object.keys() order is insertion-order
|
||||||
|
// in modern engines but Go map iteration is randomized.
|
||||||
|
sortInPlace(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenNotes coerces row[key] from string OR []string into a clean
|
||||||
|
// non-empty []string. TS form `[x].flat().filter(Boolean)` — Go does
|
||||||
|
// it explicitly.
|
||||||
|
func flattenNotes(row map[string]any, key string) []string {
|
||||||
|
v, ok := row[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
if t == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{t}
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(t))
|
||||||
|
for _, e := range t {
|
||||||
|
if s, ok := e.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeToMS parses an ISO 8601 string and returns milliseconds since
|
||||||
|
// epoch, matching TS `new Date(iso).getTime()`. Returns 0 on parse
|
||||||
|
// failure (matches TS NaN coerced to 0 by Number(...) in run_id paths,
|
||||||
|
// although there it'd produce "NaN" — the Go behavior is more useful).
|
||||||
|
func timeToMS(iso string) int64 {
|
||||||
|
if iso == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||||
|
if t, err := time.Parse(layout, iso); err == nil {
|
||||||
|
return t.UnixMilli()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRetrievedContext assembles RetrievedContext from a flat map of
|
||||||
|
// already-coerced fields. Returns nil when nothing meaningful is set,
|
||||||
|
// so transforms can attach the field conditionally without wrapping
|
||||||
|
// the call site.
|
||||||
|
func buildRetrievedContext(fields map[string]any) *distillation.RetrievedContext {
|
||||||
|
rc := distillation.RetrievedContext{}
|
||||||
|
any := false
|
||||||
|
if v, ok := fields["matrix_corpora"].([]string); ok && len(v) > 0 {
|
||||||
|
rc.MatrixCorpora = v
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
if v, ok := numFromAny(fields["matrix_hits"]); ok {
|
||||||
|
rc.MatrixHits = int(v)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
if v, ok := numFromAny(fields["matrix_chunks_kept"]); ok {
|
||||||
|
rc.MatrixChunksKept = int(v)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
if v, ok := numFromAny(fields["matrix_chunks_dropped"]); ok {
|
||||||
|
rc.MatrixChunksDropped = int(v)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
if v, ok := numFromAny(fields["pathway_fingerprints_seen"]); ok {
|
||||||
|
rc.PathwayFingerprintsSeen = int(v)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
if !any {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &rc
|
||||||
|
}
|
||||||
|
|
||||||
|
func numFromAny(v any) (float64, bool) {
|
||||||
|
if v == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return t, true
|
||||||
|
case int:
|
||||||
|
return float64(t), true
|
||||||
|
case int64:
|
||||||
|
return float64(t), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortInPlace(s []string) {
|
||||||
|
// Tiny insertion sort — corpus lists are typically <10 entries.
|
||||||
|
for i := 1; i < len(s); i++ {
|
||||||
|
for j := i; j > 0 && s[j-1] > s[j]; j-- {
|
||||||
|
s[j-1], s[j] = s[j], s[j-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package materializer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/distillation"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fixedRecordedAt = "2026-05-02T00:00:00Z"
|
||||||
|
const fixedSigHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
func ti(row map[string]any, source string, lineOffset int64) TransformInput {
|
||||||
|
return TransformInput{
|
||||||
|
Row: row,
|
||||||
|
LineOffset: lineOffset,
|
||||||
|
SourceFileRelPath: source,
|
||||||
|
RecordedAt: fixedRecordedAt,
|
||||||
|
SigHash: fixedSigHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractorTransform_DistilledFacts(t *testing.T) {
|
||||||
|
in := ti(map[string]any{
|
||||||
|
"run_id": "run-1",
|
||||||
|
"source_label": "lab-3",
|
||||||
|
"created_at": "2026-04-01T00:00:00Z",
|
||||||
|
"extractor": "qwen3.5:latest",
|
||||||
|
"text": "Hello.",
|
||||||
|
}, "data/_kb/distilled_facts.jsonl", 0)
|
||||||
|
rec := extractorTransform(in)
|
||||||
|
if rec == nil {
|
||||||
|
t.Fatal("nil record")
|
||||||
|
}
|
||||||
|
if rec.RunID != "run-1" || rec.TaskID != "lab-3" {
|
||||||
|
t.Fatalf("ids: %+v", rec)
|
||||||
|
}
|
||||||
|
if rec.ModelRole != distillation.RoleExtractor {
|
||||||
|
t.Errorf("role=%v, want extractor", rec.ModelRole)
|
||||||
|
}
|
||||||
|
if rec.ModelProvider != "ollama" {
|
||||||
|
t.Errorf("provider=%q, want ollama", rec.ModelProvider)
|
||||||
|
}
|
||||||
|
if rec.Provenance.SigHash != fixedSigHash {
|
||||||
|
t.Errorf("provenance.sig_hash mismatch: %q", rec.Provenance.SigHash)
|
||||||
|
}
|
||||||
|
if rec.Text != "Hello." {
|
||||||
|
t.Errorf("text=%q", rec.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractorTransform_FallbackIDs(t *testing.T) {
|
||||||
|
in := ti(map[string]any{
|
||||||
|
"created_at": "2026-04-01T00:00:00Z",
|
||||||
|
"text": "row without ids",
|
||||||
|
}, "data/_kb/distilled_procedures.jsonl", 7)
|
||||||
|
rec := extractorTransform(in)
|
||||||
|
if rec.RunID != "distilled_procedures:7" || rec.TaskID != "distilled_procedures:7" {
|
||||||
|
t.Fatalf("fallback ids wrong: %+v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractAnalysesTransform_Fields(t *testing.T) {
|
||||||
|
in := ti(map[string]any{
|
||||||
|
"permit_id": "P-001",
|
||||||
|
"ts": "2026-04-26T12:00:00Z",
|
||||||
|
"matrix_corpora": map[string]any{"workers": 1, "candidates": 1},
|
||||||
|
"matrix_hits": 3.0,
|
||||||
|
"observer_notes": []any{"good", "spec match"},
|
||||||
|
"observer_verdict": "accept",
|
||||||
|
"observer_conf": 85.0,
|
||||||
|
"ok": true,
|
||||||
|
"cost": 2_500_000.0, // micro-units
|
||||||
|
"duration_ms": 1234.0,
|
||||||
|
"contractor": "Acme",
|
||||||
|
"analysis": "Looks good.",
|
||||||
|
}, "data/_kb/contract_analyses.jsonl", 0)
|
||||||
|
rec := contractAnalysesTransform(in)
|
||||||
|
if rec.RunID == "" || rec.TaskID != "permit:P-001" {
|
||||||
|
t.Fatalf("ids: %+v", rec)
|
||||||
|
}
|
||||||
|
if rec.ModelRole != distillation.RoleExecutor {
|
||||||
|
t.Errorf("role=%v", rec.ModelRole)
|
||||||
|
}
|
||||||
|
if rec.RetrievedContext == nil || len(rec.RetrievedContext.MatrixCorpora) != 2 || rec.RetrievedContext.MatrixHits != 3 {
|
||||||
|
t.Errorf("retrieved_context wrong: %+v", rec.RetrievedContext)
|
||||||
|
}
|
||||||
|
if len(rec.ObserverNotes) != 2 {
|
||||||
|
t.Errorf("observer_notes=%v", rec.ObserverNotes)
|
||||||
|
}
|
||||||
|
if string(rec.ObserverVerdict) != "accept" || rec.ObserverConfidence != 85 {
|
||||||
|
t.Errorf("observer fields: %+v", rec)
|
||||||
|
}
|
||||||
|
if rec.CostUSD != 2.5 {
|
||||||
|
t.Errorf("cost should convert micro→USD; got %v", rec.CostUSD)
|
||||||
|
}
|
||||||
|
if rec.LatencyMs != 1234 {
|
||||||
|
t.Errorf("latency: %v", rec.LatencyMs)
|
||||||
|
}
|
||||||
|
if rec.Metadata == nil || rec.Metadata["contractor"] != "Acme" {
|
||||||
|
t.Errorf("metadata.contractor missing: %v", rec.Metadata)
|
||||||
|
}
|
||||||
|
if len(rec.SuccessMarkers) != 1 || rec.SuccessMarkers[0] != "matrix_hits_above_threshold" {
|
||||||
|
t.Errorf("success_markers: %v", rec.SuccessMarkers)
|
||||||
|
}
|
||||||
|
if len(rec.FailureMarkers) != 0 {
|
||||||
|
t.Errorf("expected no failure_markers when ok=true and verdict=accept, got %v", rec.FailureMarkers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractAnalysesTransform_FailureMarkers(t *testing.T) {
|
||||||
|
in := ti(map[string]any{
|
||||||
|
"permit_id": "P-002",
|
||||||
|
"ts": "2026-04-26T12:00:00Z",
|
||||||
|
"observer_verdict": "reject",
|
||||||
|
"ok": false,
|
||||||
|
"analysis": "Issues found.",
|
||||||
|
}, "data/_kb/contract_analyses.jsonl", 1)
|
||||||
|
rec := contractAnalysesTransform(in)
|
||||||
|
if len(rec.FailureMarkers) != 1 || rec.FailureMarkers[0] != "observer_rejected" {
|
||||||
|
t.Errorf("failure_markers: %v", rec.FailureMarkers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeExperimentsTransform_ProviderInference(t *testing.T) {
|
||||||
|
openrouter := ti(map[string]any{
|
||||||
|
"ts": "2026-04-26T12:00:00Z",
|
||||||
|
"task_class": "scrum_review",
|
||||||
|
"model": "anthropic/claude-opus-4-7",
|
||||||
|
"file_path": "src/foo.rs",
|
||||||
|
"sources": map[string]any{"matrix_corpus": []any{"docs"}, "matrix_chunks_kept": 4.0},
|
||||||
|
"latency_ms": 200.0,
|
||||||
|
"response": "ok",
|
||||||
|
}, "data/_kb/mode_experiments.jsonl", 0)
|
||||||
|
rec := modeExperimentsTransform(openrouter)
|
||||||
|
if rec.ModelProvider != "openrouter" {
|
||||||
|
t.Errorf("provider=%q, want openrouter", rec.ModelProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
cloud := ti(map[string]any{
|
||||||
|
"ts": "2026-04-26T12:00:00Z",
|
||||||
|
"task_class": "scrum_review",
|
||||||
|
"model": "qwen3-coder:480b",
|
||||||
|
"sources": map[string]any{"matrix_corpus": []any{"docs"}},
|
||||||
|
"response": "ok",
|
||||||
|
}, "data/_kb/mode_experiments.jsonl", 1)
|
||||||
|
rec2 := modeExperimentsTransform(cloud)
|
||||||
|
if rec2.ModelProvider != "ollama_cloud" {
|
||||||
|
t.Errorf("provider=%q, want ollama_cloud", rec2.ModelProvider)
|
||||||
|
}
|
||||||
|
if len(rec2.SourceFiles) != 0 {
|
||||||
|
t.Errorf("source_files should be empty when file_path missing; got %v", rec2.SourceFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserverEscalationsTransform_Tokens(t *testing.T) {
|
||||||
|
in := ti(map[string]any{
|
||||||
|
"ts": "2026-04-26T12:00:00Z",
|
||||||
|
"sig_hash": "abc",
|
||||||
|
"cluster_endpoint": "/v1/chat",
|
||||||
|
"prompt_tokens": 100.0,
|
||||||
|
"completion_tokens": 50.0,
|
||||||
|
"analysis": "review",
|
||||||
|
}, "data/_kb/observer_escalations.jsonl", 0)
|
||||||
|
rec := observerEscalationsTransform(in)
|
||||||
|
if rec.PromptTokens != 100 || rec.CompletionTokens != 50 {
|
||||||
|
t.Errorf("tokens: prompt=%d completion=%d", rec.PromptTokens, rec.CompletionTokens)
|
||||||
|
}
|
||||||
|
if rec.TaskID != "observer_escalation:/v1/chat" {
|
||||||
|
t.Errorf("task_id=%q", rec.TaskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditFactsTransform_TextIsSummary(t *testing.T) {
|
||||||
|
in := ti(map[string]any{
|
||||||
|
"head_sha": "abc123",
|
||||||
|
"pr_number": 11.0,
|
||||||
|
"extracted_at": "2026-04-26T12:00:00Z",
|
||||||
|
"extractor": "qwen2.5",
|
||||||
|
"facts": []any{"f1", "f2"},
|
||||||
|
"entities": []any{"e1"},
|
||||||
|
"relationships": []any{},
|
||||||
|
}, "data/_kb/audit_facts.jsonl", 0)
|
||||||
|
rec := auditFactsTransform(in)
|
||||||
|
var summary map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(rec.Text), &summary); err != nil {
|
||||||
|
t.Fatalf("text not JSON: %v", err)
|
||||||
|
}
|
||||||
|
if summary["facts"].(float64) != 2 || summary["entities"].(float64) != 1 || summary["relationships"].(float64) != 0 {
|
||||||
|
t.Errorf("counts wrong: %+v", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoApplyTransform_DeterministicTimestampFallback(t *testing.T) {
|
||||||
|
in := ti(map[string]any{
|
||||||
|
"action": "committed",
|
||||||
|
"file": "src/x.rs",
|
||||||
|
}, "data/_kb/auto_apply.jsonl", 0)
|
||||||
|
rec := autoApplyTransform(in)
|
||||||
|
if rec.Timestamp != fixedRecordedAt {
|
||||||
|
t.Errorf("expected fallback to RecordedAt %q, got %q", fixedRecordedAt, rec.Timestamp)
|
||||||
|
}
|
||||||
|
if len(rec.SuccessMarkers) != 1 || rec.SuccessMarkers[0] != "committed" {
|
||||||
|
t.Errorf("success_markers: %v", rec.SuccessMarkers)
|
||||||
|
}
|
||||||
|
|
||||||
|
revertedIn := ti(map[string]any{
|
||||||
|
"ts": "2026-04-26T12:00:00Z",
|
||||||
|
"action": "auto_reverted_after_test_fail",
|
||||||
|
"file": "src/x.rs",
|
||||||
|
}, "data/_kb/auto_apply.jsonl", 1)
|
||||||
|
rec2 := autoApplyTransform(revertedIn)
|
||||||
|
if len(rec2.FailureMarkers) != 1 || rec2.FailureMarkers[0] != "auto_reverted_after_test_fail" {
|
||||||
|
t.Errorf("failure_markers: %v", rec2.FailureMarkers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditsTransform_SeverityRouting(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
sev string
|
||||||
|
success bool
|
||||||
|
blocking bool
|
||||||
|
medium bool
|
||||||
|
}{
|
||||||
|
{"info", true, false, false},
|
||||||
|
{"low", true, false, false},
|
||||||
|
{"medium", false, false, true},
|
||||||
|
{"high", false, true, false},
|
||||||
|
{"critical", false, true, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.sev, func(t *testing.T) {
|
||||||
|
in := ti(map[string]any{
|
||||||
|
"finding_id": "F-1",
|
||||||
|
"phase": "G2",
|
||||||
|
"severity": c.sev,
|
||||||
|
"ts": "2026-04-26T12:00:00Z",
|
||||||
|
"evidence": "details",
|
||||||
|
}, "data/_kb/audits.jsonl", 0)
|
||||||
|
rec := auditsTransform(in)
|
||||||
|
hasSuccess := len(rec.SuccessMarkers) > 0
|
||||||
|
hasFailure := len(rec.FailureMarkers) > 0
|
||||||
|
if hasSuccess != c.success {
|
||||||
|
t.Errorf("severity=%s success=%v wanted %v", c.sev, hasSuccess, c.success)
|
||||||
|
}
|
||||||
|
if hasFailure != (c.blocking || c.medium) {
|
||||||
|
t.Errorf("severity=%s failure=%v wanted %v", c.sev, hasFailure, c.blocking || c.medium)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutcomesTransform_LatencyAndSuccess(t *testing.T) {
|
||||||
|
in := ti(map[string]any{
|
||||||
|
"run_id": "r-1",
|
||||||
|
"created_at": "2026-04-26T12:00:00Z",
|
||||||
|
"sig_hash": "abc",
|
||||||
|
"elapsed_secs": 1.234,
|
||||||
|
"ok_events": 5.0,
|
||||||
|
"total_events": 5.0,
|
||||||
|
"total_gap_signals": 2.0,
|
||||||
|
"total_citations": 3.0,
|
||||||
|
}, "data/_kb/outcomes.jsonl", 0)
|
||||||
|
rec := outcomesTransform(in)
|
||||||
|
if rec.LatencyMs != 1234 {
|
||||||
|
t.Errorf("latency=%d", rec.LatencyMs)
|
||||||
|
}
|
||||||
|
if len(rec.SuccessMarkers) != 1 || rec.SuccessMarkers[0] != "all_events_ok" {
|
||||||
|
t.Errorf("success: %v", rec.SuccessMarkers)
|
||||||
|
}
|
||||||
|
if g, ok := rec.ValidationResults["gap_signals"].(int64); !ok || g != 2 {
|
||||||
|
t.Errorf("gap_signals: %v", rec.ValidationResults)
|
||||||
|
}
|
||||||
|
if c, ok := rec.ValidationResults["citation_count"].(int64); !ok || c != 3 {
|
||||||
|
t.Errorf("citation_count: %v", rec.ValidationResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransformByPath_Found(t *testing.T) {
|
||||||
|
td := TransformByPath("data/_kb/distilled_facts.jsonl")
|
||||||
|
if td == nil {
|
||||||
|
t.Fatal("expected to find distilled_facts transform")
|
||||||
|
}
|
||||||
|
if TransformByPath("data/_kb/never_existed.jsonl") != nil {
|
||||||
|
t.Fatal("expected nil for unknown path")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package materializer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/distillation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateEvidenceRecord ports validateEvidenceRecord from
|
||||||
|
// auditor/schemas/distillation/evidence_record.ts. Returns nil on
|
||||||
|
// success or a slice of human-readable error messages — the
|
||||||
|
// materializer logs the slice into distillation_skips.jsonl so an
|
||||||
|
// operator can see why a row was rejected without diff'ing logic.
|
||||||
|
//
|
||||||
|
// The validator is intentionally separate from
|
||||||
|
// distillation.ValidateScoredRun: scoring runs and evidence records
|
||||||
|
// have different shapes and the scorer's validator only covers the
|
||||||
|
// scored-run side.
|
||||||
|
func ValidateEvidenceRecord(r distillation.EvidenceRecord) []string {
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
if r.RunID == "" {
|
||||||
|
errs = append(errs, "run_id: must be non-empty")
|
||||||
|
}
|
||||||
|
if r.TaskID == "" {
|
||||||
|
errs = append(errs, "task_id: must be non-empty")
|
||||||
|
}
|
||||||
|
if !validISOTimestamp(r.Timestamp) {
|
||||||
|
errs = append(errs, fmt.Sprintf("timestamp: not a valid ISO 8601 timestamp: %s", trim(r.Timestamp, 60)))
|
||||||
|
}
|
||||||
|
if r.SchemaVersion != distillation.EvidenceSchemaVersion {
|
||||||
|
errs = append(errs, fmt.Sprintf("schema_version: expected %d, got %d", distillation.EvidenceSchemaVersion, r.SchemaVersion))
|
||||||
|
}
|
||||||
|
errs = append(errs, validateProvenanceFields(r.Provenance)...)
|
||||||
|
|
||||||
|
if r.ModelRole != "" && !isValidModelRole(r.ModelRole) {
|
||||||
|
errs = append(errs, fmt.Sprintf("model_role: must be a known role, got %q", r.ModelRole))
|
||||||
|
}
|
||||||
|
if r.InputHash != "" && !isHexSha256(r.InputHash) {
|
||||||
|
errs = append(errs, "input_hash: must be hex sha256 when present")
|
||||||
|
}
|
||||||
|
if r.OutputHash != "" && !isHexSha256(r.OutputHash) {
|
||||||
|
errs = append(errs, "output_hash: must be hex sha256 when present")
|
||||||
|
}
|
||||||
|
if r.ObserverConfidence < 0 || r.ObserverConfidence > 100 {
|
||||||
|
errs = append(errs, "observer_confidence: must be in [0, 100]")
|
||||||
|
}
|
||||||
|
if r.HumanOverride != nil {
|
||||||
|
if r.HumanOverride.Overrider == "" {
|
||||||
|
errs = append(errs, "human_override.overrider: must be non-empty")
|
||||||
|
}
|
||||||
|
if r.HumanOverride.Reason == "" {
|
||||||
|
errs = append(errs, "human_override.reason: must be non-empty")
|
||||||
|
}
|
||||||
|
if !validISOTimestamp(r.HumanOverride.OverriddenAt) {
|
||||||
|
errs = append(errs, "human_override.overridden_at: must be ISO 8601")
|
||||||
|
}
|
||||||
|
switch r.HumanOverride.Decision {
|
||||||
|
case "accept", "reject", "needs_review":
|
||||||
|
default:
|
||||||
|
errs = append(errs, "human_override.decision: must be accept|reject|needs_review")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProvenanceFields(p distillation.Provenance) []string {
|
||||||
|
var errs []string
|
||||||
|
if p.SourceFile == "" {
|
||||||
|
errs = append(errs, "provenance.source_file: must be non-empty")
|
||||||
|
}
|
||||||
|
if !isHexSha256(p.SigHash) {
|
||||||
|
errs = append(errs, fmt.Sprintf("provenance.sig_hash: not a valid hex sha256: %s", trim(p.SigHash, 80)))
|
||||||
|
}
|
||||||
|
if !validISOTimestamp(p.RecordedAt) {
|
||||||
|
errs = append(errs, "provenance.recorded_at: must be ISO 8601")
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Permissive ISO 8601 (matches TS regex):
|
||||||
|
// YYYY-MM-DDTHH:MM:SS(.fraction)?(Z|±HH:MM)?
|
||||||
|
isoTimestampRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$`)
|
||||||
|
hexSha256RE = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func validISOTimestamp(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !isoTimestampRE.MatchString(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Belt-and-suspenders: confirm it's actually parseable too.
|
||||||
|
if _, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexSha256(s string) bool {
|
||||||
|
return hexSha256RE.MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidModelRole(role distillation.ModelRole) bool {
|
||||||
|
switch role {
|
||||||
|
case distillation.RoleExecutor, distillation.RoleReviewer, distillation.RoleExtractor,
|
||||||
|
distillation.RoleVerifier, distillation.RoleCategorizer, distillation.RoleTiebreaker,
|
||||||
|
distillation.RoleApplier, distillation.RoleEmbedder, distillation.RoleOther:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func trim(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(s[:n], "\n", " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
// Strong-model auto-downgrade gate. Port of mode.rs::execute's
|
||||||
|
// downgrade block (Rust system, 2026-04-26 pass5).
|
||||||
|
//
|
||||||
|
// What it does: if the caller resolves `codereview_lakehouse` against
|
||||||
|
// a strong model and didn't force the mode, flip to
|
||||||
|
// `codereview_isolation` so we don't pollute the prompt with matrix
|
||||||
|
// chunks the model demonstrably does better without.
|
||||||
|
//
|
||||||
|
// Why: pass5 variance test on x-ai/grok-4.1-fast — composing matrix
|
||||||
|
// corpora into codereview_lakehouse LOST 5/5 head-to-head reps
|
||||||
|
// against matrix-free codereview_isolation, p=0.031. Strong models
|
||||||
|
// have enough native capacity that bug fingerprints + adversarial
|
||||||
|
// framing + file content carry them; matrix chunks displace
|
||||||
|
// depth-of-analysis.
|
||||||
|
//
|
||||||
|
// Defaults: assume "strong" (downgrade matrix off). The explicit
|
||||||
|
// IsWeakModel predicate keeps the weak-list small — anything
|
||||||
|
// matching `:free` (OpenRouter free tier) or the local last-resort
|
||||||
|
// rungs (qwen3.5/qwen3) stays on the full lakehouse path where
|
||||||
|
// matrix demonstrably helped during the 2026-04-26 free-tier
|
||||||
|
// bake-off.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mode constants — exported so callers don't string-literal them.
|
||||||
|
const (
|
||||||
|
ModeCodeReviewLakehouse = "codereview_lakehouse"
|
||||||
|
ModeCodeReviewIsolation = "codereview_isolation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnvForceFullEnrichment is the env var that bypasses the gate for
|
||||||
|
// diagnostic runs ("LH_FORCE_FULL_ENRICHMENT=1" or "true").
|
||||||
|
const EnvForceFullEnrichment = "LH_FORCE_FULL_ENRICHMENT"
|
||||||
|
|
||||||
|
// DefaultWeakModels is the fallback weak-model list used when no
|
||||||
|
// config-driven list is provided. Matches the pre-config hardcoded
|
||||||
|
// switch — preserved for backward compatibility with callers that
|
||||||
|
// haven't migrated to the [models] tier config (Phase 1).
|
||||||
|
var DefaultWeakModels = []string{"qwen3.5:latest", "qwen3:latest"}
|
||||||
|
|
||||||
|
// IsWeakModel returns true for models matrix-corpus composition
|
||||||
|
// demonstrably helped during the 2026-04-26 pass5 bake-off. Strong
|
||||||
|
// models (default) get matrix dropped to avoid the "composed lost
|
||||||
|
// 5/5 vs isolation" effect.
|
||||||
|
//
|
||||||
|
// Weak signals:
|
||||||
|
// - `:free` suffix (OpenRouter free tier, e.g. `gpt-oss-120b:free`)
|
||||||
|
// - `:free/` infix (handles routing-prefixed names like `or:free/x`)
|
||||||
|
// - DefaultWeakModels list — local-hot-path eligible.
|
||||||
|
//
|
||||||
|
// For config-driven lists, use IsWeakModelInList.
|
||||||
|
func IsWeakModel(model string) bool {
|
||||||
|
return IsWeakModelInList(model, DefaultWeakModels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWeakModelInList is the config-aware variant. Callers pass the
|
||||||
|
// configured weak-model list (typically cfg.Models.WeakModels) and
|
||||||
|
// the function applies the same rule-based checks (`:free` suffix /
|
||||||
|
// infix) plus the literal list match.
|
||||||
|
//
|
||||||
|
// Empty list still applies the rule-based checks — only the literal
|
||||||
|
// match becomes a no-op. That preserves "free-tier always weak"
|
||||||
|
// regardless of what the operator configured.
|
||||||
|
func IsWeakModelInList(model string, weakList []string) bool {
|
||||||
|
if strings.HasSuffix(model, ":free") || strings.Contains(model, ":free/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, w := range weakList {
|
||||||
|
if w == model {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DowngradeInput is what MaybeDowngrade evaluates.
|
||||||
|
//
|
||||||
|
// ForcedMode: caller explicitly set their mode (mirrors Rust's
|
||||||
|
// req.force_mode.is_some()) — treated as opt-in to the chosen mode,
|
||||||
|
// skips the downgrade. Experiments need exact-mode control.
|
||||||
|
//
|
||||||
|
// ForceFullOverride: the LH_FORCE_FULL_ENRICHMENT escape hatch —
|
||||||
|
// usually populated from the env var via NewDowngradeInputFromEnv,
|
||||||
|
// but the field is explicit so callers can pass it from a config or
|
||||||
|
// test deterministically.
|
||||||
|
type DowngradeInput struct {
|
||||||
|
Mode string
|
||||||
|
Model string
|
||||||
|
ForcedMode bool
|
||||||
|
ForceFullOverride bool
|
||||||
|
// WeakModels overrides the default weak-model list (DefaultWeakModels)
|
||||||
|
// when non-nil. Typically populated from cfg.Models.WeakModels at
|
||||||
|
// startup. nil = use DefaultWeakModels (backward compat).
|
||||||
|
WeakModels []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DowngradeDecision is the output. DowngradedFrom is non-empty
|
||||||
|
// only when a downgrade fired — callers should record it for audit
|
||||||
|
// (matches the Rust EnrichmentSources.downgraded_from field).
|
||||||
|
//
|
||||||
|
// Reason is a short human-readable string for logs/responses;
|
||||||
|
// useful for debugging "why did/didn't the gate fire."
|
||||||
|
type DowngradeDecision struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
DowngradedFrom string `json:"downgraded_from,omitempty"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaybeDowngrade applies the strong-model auto-downgrade gate.
|
||||||
|
// Pure function; no env reads. For env-driven callers see
|
||||||
|
// NewDowngradeInputFromEnv.
|
||||||
|
func MaybeDowngrade(in DowngradeInput) DowngradeDecision {
|
||||||
|
out := DowngradeDecision{Mode: in.Mode}
|
||||||
|
if in.Mode != ModeCodeReviewLakehouse {
|
||||||
|
out.Reason = "mode is not " + ModeCodeReviewLakehouse + "; gate not applicable"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if in.ForcedMode {
|
||||||
|
out.Reason = "caller forced mode; skip downgrade"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if in.ForceFullOverride {
|
||||||
|
out.Reason = EnvForceFullEnrichment + " bypass"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
weakList := in.WeakModels
|
||||||
|
if weakList == nil {
|
||||||
|
weakList = DefaultWeakModels
|
||||||
|
}
|
||||||
|
if IsWeakModelInList(in.Model, weakList) {
|
||||||
|
out.Reason = "weak model; matrix composition demonstrably helped (2026-04-26 free-tier bake-off)"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// Downgrade fires.
|
||||||
|
out.Mode = ModeCodeReviewIsolation
|
||||||
|
out.DowngradedFrom = ModeCodeReviewLakehouse
|
||||||
|
out.Reason = "strong model; matrix composes anti-additively (pass5: composed lost 5/5 vs isolation on grok-4.1-fast, p=0.031)"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDowngradeInputFromEnv is a convenience that reads
|
||||||
|
// LH_FORCE_FULL_ENRICHMENT from the process environment and returns
|
||||||
|
// a populated DowngradeInput. Most production callers want this;
|
||||||
|
// tests should construct DowngradeInput directly to avoid env
|
||||||
|
// pollution.
|
||||||
|
func NewDowngradeInputFromEnv(mode, model string, forcedMode bool) DowngradeInput {
|
||||||
|
return DowngradeInput{
|
||||||
|
Mode: mode,
|
||||||
|
Model: model,
|
||||||
|
ForcedMode: forcedMode,
|
||||||
|
ForceFullOverride: envForceFullEnrichment(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envForceFullEnrichment() bool {
|
||||||
|
v := strings.ToLower(strings.TrimSpace(os.Getenv(EnvForceFullEnrichment)))
|
||||||
|
return v == "1" || v == "true"
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsWeakModel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
model string
|
||||||
|
weak bool
|
||||||
|
}{
|
||||||
|
// :free suffix → weak
|
||||||
|
{"openai/gpt-4o:free", true},
|
||||||
|
{"meta-llama/llama-3-8b:free", true},
|
||||||
|
// :free/ infix (routing-prefixed names)
|
||||||
|
{"openrouter:free/anthropic/claude-3.5-sonnet", true},
|
||||||
|
// Local last-resort rungs
|
||||||
|
{"qwen3.5:latest", true},
|
||||||
|
{"qwen3:latest", true},
|
||||||
|
// Strong by default
|
||||||
|
{"x-ai/grok-4.1-fast", false},
|
||||||
|
{"opencode/claude-opus-4-7", false},
|
||||||
|
{"openai/gpt-5", false},
|
||||||
|
{"qwen3-coder:480b", false}, // not the :latest tag
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := IsWeakModel(c.model)
|
||||||
|
if got != c.weak {
|
||||||
|
t.Errorf("IsWeakModel(%q): want %v, got %v", c.model, c.weak, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaybeDowngrade_TruthTable(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in DowngradeInput
|
||||||
|
want DowngradeDecision
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "downgrade fires: lakehouse mode + strong model + no force",
|
||||||
|
in: DowngradeInput{Mode: ModeCodeReviewLakehouse, Model: "x-ai/grok-4.1-fast"},
|
||||||
|
want: DowngradeDecision{
|
||||||
|
Mode: ModeCodeReviewIsolation,
|
||||||
|
DowngradedFrom: ModeCodeReviewLakehouse,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no downgrade: forced mode bypasses gate",
|
||||||
|
in: DowngradeInput{Mode: ModeCodeReviewLakehouse, Model: "x-ai/grok-4.1-fast", ForcedMode: true},
|
||||||
|
want: DowngradeDecision{Mode: ModeCodeReviewLakehouse},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no downgrade: env override bypasses gate",
|
||||||
|
in: DowngradeInput{Mode: ModeCodeReviewLakehouse, Model: "x-ai/grok-4.1-fast", ForceFullOverride: true},
|
||||||
|
want: DowngradeDecision{Mode: ModeCodeReviewLakehouse},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no downgrade: weak model keeps lakehouse",
|
||||||
|
in: DowngradeInput{Mode: ModeCodeReviewLakehouse, Model: "openai/gpt-4o:free"},
|
||||||
|
want: DowngradeDecision{Mode: ModeCodeReviewLakehouse},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no downgrade: non-lakehouse mode (gate not applicable)",
|
||||||
|
in: DowngradeInput{Mode: "codereview_isolation", Model: "x-ai/grok-4.1-fast"},
|
||||||
|
want: DowngradeDecision{Mode: "codereview_isolation"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := MaybeDowngrade(c.in)
|
||||||
|
if got.Mode != c.want.Mode {
|
||||||
|
t.Errorf("%s: Mode want %q, got %q", c.name, c.want.Mode, got.Mode)
|
||||||
|
}
|
||||||
|
if got.DowngradedFrom != c.want.DowngradedFrom {
|
||||||
|
t.Errorf("%s: DowngradedFrom want %q, got %q", c.name, c.want.DowngradedFrom, got.DowngradedFrom)
|
||||||
|
}
|
||||||
|
if got.Reason == "" {
|
||||||
|
t.Errorf("%s: Reason should be non-empty", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsWeakModelInList covers the config-driven variant added in
|
||||||
|
// Phase 2 — callers pass cfg.Models.WeakModels instead of relying on
|
||||||
|
// the package-level DefaultWeakModels.
|
||||||
|
func TestIsWeakModelInList(t *testing.T) {
|
||||||
|
custom := []string{"my-custom-7b:latest", "qwen3:latest"}
|
||||||
|
cases := []struct {
|
||||||
|
model string
|
||||||
|
list []string
|
||||||
|
weak bool
|
||||||
|
}{
|
||||||
|
// Custom literal hits (config-driven)
|
||||||
|
{"my-custom-7b:latest", custom, true},
|
||||||
|
{"qwen3:latest", custom, true},
|
||||||
|
// qwen3.5:latest is in DefaultWeakModels but NOT in custom list →
|
||||||
|
// not weak when caller supplies custom list (intentional — operator
|
||||||
|
// owns the list).
|
||||||
|
{"qwen3.5:latest", custom, false},
|
||||||
|
// :free/free-tier rules apply regardless of list
|
||||||
|
{"openai/gpt-4o:free", custom, true},
|
||||||
|
{"openrouter:free/anthropic/claude", custom, true},
|
||||||
|
// Empty list → only rule-based checks apply
|
||||||
|
{"qwen3.5:latest", []string{}, false},
|
||||||
|
{"openai/gpt-4o:free", []string{}, true},
|
||||||
|
// nil list behaves like empty (caller hasn't migrated yet)
|
||||||
|
{"qwen3.5:latest", nil, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := IsWeakModelInList(c.model, c.list)
|
||||||
|
if got != c.weak {
|
||||||
|
t.Errorf("IsWeakModelInList(%q, %v): want %v, got %v", c.model, c.list, c.weak, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaybeDowngrade_WithConfigList verifies the DowngradeInput.WeakModels
|
||||||
|
// field overrides DefaultWeakModels when populated, so callers reading
|
||||||
|
// cfg.Models.WeakModels at startup get the configured behavior end-to-end.
|
||||||
|
func TestMaybeDowngrade_WithConfigList(t *testing.T) {
|
||||||
|
// Config says "only my-custom is weak". qwen3.5:latest is now strong
|
||||||
|
// (operator opted out of the local-hot-path treatment).
|
||||||
|
configList := []string{"my-custom:latest"}
|
||||||
|
in := DowngradeInput{
|
||||||
|
Mode: ModeCodeReviewLakehouse,
|
||||||
|
Model: "qwen3.5:latest",
|
||||||
|
WeakModels: configList,
|
||||||
|
}
|
||||||
|
got := MaybeDowngrade(in)
|
||||||
|
if got.Mode != ModeCodeReviewIsolation {
|
||||||
|
t.Errorf("qwen3.5:latest with config list excluding it should downgrade; got Mode=%q", got.Mode)
|
||||||
|
}
|
||||||
|
if got.DowngradedFrom != ModeCodeReviewLakehouse {
|
||||||
|
t.Errorf("DowngradedFrom should be lakehouse; got %q", got.DowngradedFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same input but weak by config — gate must NOT fire.
|
||||||
|
in.Model = "my-custom:latest"
|
||||||
|
got = MaybeDowngrade(in)
|
||||||
|
if got.Mode != ModeCodeReviewLakehouse {
|
||||||
|
t.Errorf("my-custom:latest is in config list — should stay lakehouse; got %q", got.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaybeDowngrade_ForcedTrumpsOthers verifies precedence: when
|
||||||
|
// multiple bypass conditions hit, ForcedMode wins (explicit caller
|
||||||
|
// intent always overrides). Caught a subtle ordering bug in the
|
||||||
|
// original Rust code where this was tested only by happy path.
|
||||||
|
func TestMaybeDowngrade_ForcedTrumpsOthers(t *testing.T) {
|
||||||
|
in := DowngradeInput{
|
||||||
|
Mode: ModeCodeReviewLakehouse,
|
||||||
|
Model: "qwen3.5:latest", // weak — would otherwise hit weak-bypass
|
||||||
|
ForcedMode: true,
|
||||||
|
ForceFullOverride: true,
|
||||||
|
}
|
||||||
|
got := MaybeDowngrade(in)
|
||||||
|
if got.Mode != ModeCodeReviewLakehouse {
|
||||||
|
t.Errorf("forced mode should keep mode: got %q", got.Mode)
|
||||||
|
}
|
||||||
|
if got.DowngradedFrom != "" {
|
||||||
|
t.Errorf("no downgrade expected; got DowngradedFrom=%q", got.DowngradedFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchesMetadataFilter_NoFilter_KeepsAll(t *testing.T) {
|
||||||
|
meta := json.RawMessage(`{"role":"Forklift Operator","state":"IL"}`)
|
||||||
|
if !matchesMetadataFilter(meta, nil) {
|
||||||
|
t.Error("nil filter should match everything")
|
||||||
|
}
|
||||||
|
if !matchesMetadataFilter(meta, map[string]any{}) {
|
||||||
|
t.Error("empty filter should match everything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesMetadataFilter_NoMetadata_AlwaysFails(t *testing.T) {
|
||||||
|
if matchesMetadataFilter(nil, map[string]any{"x": "y"}) {
|
||||||
|
t.Error("missing metadata should fail any filter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesMetadataFilter_SingleValueExactMatch(t *testing.T) {
|
||||||
|
meta := json.RawMessage(`{"state":"IL","status":"active","years":5}`)
|
||||||
|
cases := []struct {
|
||||||
|
filter map[string]any
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{map[string]any{"state": "IL"}, true},
|
||||||
|
{map[string]any{"state": "TX"}, false},
|
||||||
|
{map[string]any{"status": "active"}, true},
|
||||||
|
{map[string]any{"status": "inactive"}, false},
|
||||||
|
// JSON normalizes both sides, so 5 matches 5.0
|
||||||
|
{map[string]any{"years": 5.0}, true},
|
||||||
|
{map[string]any{"years": 5}, true},
|
||||||
|
// Missing key = fail
|
||||||
|
{map[string]any{"city": "Chicago"}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := matchesMetadataFilter(meta, c.filter)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("filter %v on %s: want %v, got %v", c.filter, meta, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesMetadataFilter_AllKeysAND(t *testing.T) {
|
||||||
|
meta := json.RawMessage(`{"state":"IL","status":"active","role":"Forklift Operator"}`)
|
||||||
|
if !matchesMetadataFilter(meta, map[string]any{
|
||||||
|
"state": "IL",
|
||||||
|
"status": "active",
|
||||||
|
}) {
|
||||||
|
t.Error("both keys match: should pass")
|
||||||
|
}
|
||||||
|
if matchesMetadataFilter(meta, map[string]any{
|
||||||
|
"state": "IL",
|
||||||
|
"status": "inactive", // mismatch
|
||||||
|
}) {
|
||||||
|
t.Error("one key mismatches: should fail (AND across keys)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesMetadataFilter_ListValueOR(t *testing.T) {
|
||||||
|
meta := json.RawMessage(`{"state":"IL"}`)
|
||||||
|
// state in {"IL","WI","IN"} → match
|
||||||
|
if !matchesMetadataFilter(meta, map[string]any{
|
||||||
|
"state": []any{"IL", "WI", "IN"},
|
||||||
|
}) {
|
||||||
|
t.Error("list with matching element: should pass")
|
||||||
|
}
|
||||||
|
// state in {"TX","CA"} → fail
|
||||||
|
if matchesMetadataFilter(meta, map[string]any{
|
||||||
|
"state": []any{"TX", "CA"},
|
||||||
|
}) {
|
||||||
|
t.Error("list with no matching element: should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesMetadataFilter_BoolMatch(t *testing.T) {
|
||||||
|
meta := json.RawMessage(`{"available":true,"placed":false}`)
|
||||||
|
if !matchesMetadataFilter(meta, map[string]any{"available": true}) {
|
||||||
|
t.Error("bool true match")
|
||||||
|
}
|
||||||
|
if matchesMetadataFilter(meta, map[string]any{"available": false}) {
|
||||||
|
t.Error("bool true should not match false filter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesMetadataFilter_MalformedMetadataFails(t *testing.T) {
|
||||||
|
meta := json.RawMessage(`{not valid json}`)
|
||||||
|
if matchesMetadataFilter(meta, map[string]any{"x": "y"}) {
|
||||||
|
t.Error("malformed metadata should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LLMJudgeGate is an InjectGate implementation that uses an Ollama-
|
||||||
|
// compatible chat endpoint (or chatd's /v1/chat) to rate the
|
||||||
|
// (query, candidate) pair on a 1-5 rubric, then approves the
|
||||||
|
// injection iff rating >= MinRating.
|
||||||
|
//
|
||||||
|
// The HTTP path is intentionally generic — works against any
|
||||||
|
// endpoint that speaks Ollama's /api/chat shape: bare Ollama,
|
||||||
|
// chatd's /v1/chat, or anything else honoring the same JSON.
|
||||||
|
// Per-call timeout is bounded by the parent ctx + the http.Client.
|
||||||
|
//
|
||||||
|
// Best-effort posture: a judge call that fails (network, JSON
|
||||||
|
// decode, anything) returns Approve=false. Same fail-closed default
|
||||||
|
// as the inject path's distance gate — when the judge can't speak,
|
||||||
|
// don't inject (better silent miss than confident wrong-domain).
|
||||||
|
//
|
||||||
|
// Usage from retrieve.go:
|
||||||
|
// gate := matrix.NewLLMJudgeGate(req.JudgeURL, req.JudgeModel,
|
||||||
|
// req.JudgeMinRating, hc)
|
||||||
|
// results, injected = matrix.InjectPlaybookMisses(req.QueryText,
|
||||||
|
// results, hits, maxInjectDist, gate)
|
||||||
|
type LLMJudgeGate struct {
|
||||||
|
URL string
|
||||||
|
Model string
|
||||||
|
MinRating int
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLLMJudgeGate is the constructor. Defaults: minRating 3, 10s
|
||||||
|
// HTTP timeout. URL must include the path (e.g.
|
||||||
|
// "http://localhost:11434/api/chat" for bare Ollama). Returns nil
|
||||||
|
// when URL or Model is empty — caller treats nil InjectGate as
|
||||||
|
// "no judge configured, default-approve" per InjectPlaybookMisses
|
||||||
|
// contract.
|
||||||
|
func NewLLMJudgeGate(url, model string, minRating int, hc *http.Client) *LLMJudgeGate {
|
||||||
|
if url == "" || model == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if minRating <= 0 {
|
||||||
|
minRating = 3
|
||||||
|
}
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
}
|
||||||
|
return &LLMJudgeGate{
|
||||||
|
URL: url,
|
||||||
|
Model: model,
|
||||||
|
MinRating: minRating,
|
||||||
|
HTTPClient: hc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve calls the LLM judge with a query+candidate prompt; returns
|
||||||
|
// true iff the judge's rating meets MinRating. Errors return false
|
||||||
|
// (fail-closed — see type doc).
|
||||||
|
func (g *LLMJudgeGate) Approve(query string, hit PlaybookHit) bool {
|
||||||
|
if g == nil || query == "" {
|
||||||
|
// No judge or no query to judge against — treat as approve.
|
||||||
|
// Empty-query case mirrors InjectPlaybookMisses' contract:
|
||||||
|
// callers without a query string can't usefully judge.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rating := g.rate(query, hit)
|
||||||
|
return rating >= g.MinRating
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *LLMJudgeGate) rate(query string, hit PlaybookHit) int {
|
||||||
|
system := `You rate retrieval results for a staffing co-pilot.
|
||||||
|
Rate the result 1-5 against the query:
|
||||||
|
5 = perfect match (this person/role IS what was asked for)
|
||||||
|
4 = strong match (right field, right level, minor mismatches)
|
||||||
|
3 = adjacent match (related field or partial overlap)
|
||||||
|
2 = weak/tangential match
|
||||||
|
1 = irrelevant
|
||||||
|
Output JSON only: {"rating": N, "reason": "<one sentence>"}.`
|
||||||
|
// We pass the recorded query text + answer ID to give the judge
|
||||||
|
// minimal context. Production might also fetch the answer's
|
||||||
|
// metadata, but that requires a second HTTP hop; the recorded
|
||||||
|
// query is usually enough to sniff wrong-domain matches.
|
||||||
|
user := fmt.Sprintf("Query: %q\n\nCandidate playbook entry:\n recorded_query: %q\n answer_id: %s\n answer_corpus: %s\n recorded_score: %.2f",
|
||||||
|
query, hit.Entry.QueryText, hit.Entry.AnswerID, hit.Entry.AnswerCorpus, hit.Entry.Score)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"model": g.Model,
|
||||||
|
"stream": false,
|
||||||
|
"format": "json",
|
||||||
|
"messages": []map[string]string{
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
},
|
||||||
|
"options": map[string]any{"temperature": 0},
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", g.URL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("matrix.judge: build request", "err", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := g.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("matrix.judge: HTTP", "err", err, "url", g.URL)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
slog.Warn("matrix.judge: non-2xx", "status", resp.StatusCode, "url", g.URL)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rb, _ := io.ReadAll(resp.Body)
|
||||||
|
var ollamaResp struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rb, &ollamaResp); err != nil {
|
||||||
|
slog.Warn("matrix.judge: decode envelope", "err", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
Rating int `json:"rating"`
|
||||||
|
}
|
||||||
|
// Some chat endpoints wrap content in markdown code fences even
|
||||||
|
// with format=json. Strip leading/trailing whitespace + fences.
|
||||||
|
content := strings.TrimSpace(ollamaResp.Message.Content)
|
||||||
|
content = strings.TrimPrefix(content, "```json")
|
||||||
|
content = strings.TrimPrefix(content, "```")
|
||||||
|
content = strings.TrimSuffix(content, "```")
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if err := json.Unmarshal([]byte(content), &v); err != nil {
|
||||||
|
slog.Warn("matrix.judge: decode rating", "err", err, "content", content)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v.Rating < 1 || v.Rating > 5 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v.Rating
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
// Playbook memory — SPEC §3.4 component 5 (learning-loop integration).
|
||||||
|
//
|
||||||
|
// Concept: every time an external system confirms "(query → answer_id)
|
||||||
|
// was a successful match," record it. Future similar queries get that
|
||||||
|
// answer's score boosted, so the matrix indexer learns from outcomes
|
||||||
|
// rather than relying solely on the base embedder's geometry.
|
||||||
|
//
|
||||||
|
// Per feedback_meta_index_vision.md: this is the north star — a
|
||||||
|
// meta-index that LEARNS from playbooks over time, not a static
|
||||||
|
// hybrid search engine.
|
||||||
|
//
|
||||||
|
// Storage shape: a vectord index named DefaultPlaybookCorpus where:
|
||||||
|
// - The vector is embed(query_text)
|
||||||
|
// - The metadata is a serialized PlaybookEntry
|
||||||
|
// Retrieval shape: at /matrix/search time, when use_playbook=true,
|
||||||
|
// matrixd searches the playbook corpus with the same query vector,
|
||||||
|
// looks up each hit's answer_id, and if that answer is in the current
|
||||||
|
// matrix-search results, applies a boost to its distance.
|
||||||
|
//
|
||||||
|
// Composition: this layer is additive on top of the existing
|
||||||
|
// retrieve+merge — when use_playbook=false, behavior is unchanged.
|
||||||
|
// The boost only re-ranks results that ALREADY surfaced from the
|
||||||
|
// regular retrieval. A v1 enhancement would inject playbook hits
|
||||||
|
// directly even when they weren't in the top-K (Shape B from the
|
||||||
|
// design conversation), but v0 keeps the safer "boost-only" stance.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultPlaybookCorpus is the vectord index name where playbook
|
||||||
|
// entries land by default. Callers can override per-request, but
|
||||||
|
// having one default makes the system observable from the outside
|
||||||
|
// (operator hits /vectors/index and sees this corpus in the list).
|
||||||
|
const DefaultPlaybookCorpus = "playbook_memory"
|
||||||
|
|
||||||
|
// DefaultPlaybookTopK is how many similar past queries to consider
|
||||||
|
// when applying boost. 3 keeps the influence focused — we want the
|
||||||
|
// boost to reward consistent matches, not let one stale playbook
|
||||||
|
// dominate. Caller can override.
|
||||||
|
const DefaultPlaybookTopK = 3
|
||||||
|
|
||||||
|
// DefaultPlaybookMaxDistance is the cosine ceiling for "this past
|
||||||
|
// query is similar enough to count." 0.5 lets in genuinely related
|
||||||
|
// queries while excluding pure-coincidence neighbors. Caller can
|
||||||
|
// override per-request as we learn what works for staffing data.
|
||||||
|
//
|
||||||
|
// This threshold gates the BOOST path (re-rank in place), which is
|
||||||
|
// safe at loose thresholds because boost only modifies results already
|
||||||
|
// in regular retrieval. The INJECT path uses a tighter ceiling — see
|
||||||
|
// DefaultPlaybookMaxInjectDistance.
|
||||||
|
const DefaultPlaybookMaxDistance = 0.5
|
||||||
|
|
||||||
|
// DefaultPlaybookMaxInjectDistance is the SHAPE B cosine ceiling for
|
||||||
|
// "this past query is similar enough to FORCE its answer into the
|
||||||
|
// result set." Tighter than DefaultPlaybookMaxDistance because inject
|
||||||
|
// is structurally riskier than boost: it adds a result the embedding
|
||||||
|
// didn't surface, so a loose match can cross-pollinate the wrong
|
||||||
|
// answer into unrelated queries.
|
||||||
|
//
|
||||||
|
// Empirical motivation (playbook_lift_003): Q2's recording for an
|
||||||
|
// OSHA-30 forklift operator surfaced as warm top-1 for the dental
|
||||||
|
// hygienist / RN / software engineer OOD queries because their text
|
||||||
|
// vectors fell within 0.5 cosine of "OSHA-30 forklift Wisconsin."
|
||||||
|
// 0.20 would have rejected those (implied playbook distances 0.38-0.46)
|
||||||
|
// while keeping all 6 paraphrase recoveries (≤ 0.30 implied).
|
||||||
|
//
|
||||||
|
// Boost path stays at 0.5 — re-ranking results that already retrieved
|
||||||
|
// by their own merits is safe even when the playbook match is loose.
|
||||||
|
const DefaultPlaybookMaxInjectDistance = 0.20
|
||||||
|
|
||||||
|
// PlaybookEntry is what gets stored as metadata on each playbook
|
||||||
|
// vector. RecordedAt is captured at write time; callers should not
|
||||||
|
// set it (the recorder fills it in).
|
||||||
|
//
|
||||||
|
// Role is an optional caller-provided hint that names the query's
|
||||||
|
// staffing role at record time (e.g. "Forklift Operator", "CNC
|
||||||
|
// Operator"). When set, the matrix retrieve layer gates Shape A boost
|
||||||
|
// and Shape B inject so a recorded entry only fires for queries with
|
||||||
|
// the same role. Empty Role = no gate (caller didn't know the role,
|
||||||
|
// or the query has no clean role concept — e.g. lift-suite multi-
|
||||||
|
// constraint queries). Reality test real_001 surfaced same-client+city
|
||||||
|
// queries bleeding across roles when the embedder collapsed them
|
||||||
|
// within the inject distance threshold; the role gate closes that
|
||||||
|
// without rejecting legitimate intra-role boosts.
|
||||||
|
type PlaybookEntry struct {
|
||||||
|
QueryText string `json:"query_text"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
AnswerID string `json:"answer_id"`
|
||||||
|
AnswerCorpus string `json:"answer_corpus"`
|
||||||
|
Score float64 `json:"score"` // 0..1; higher = better outcome
|
||||||
|
RecordedAtNs int64 `json:"recorded_at_ns"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate returns an error if the entry is missing required fields.
|
||||||
|
// Callers should validate before storage so bad data doesn't pollute
|
||||||
|
// the corpus.
|
||||||
|
func (p PlaybookEntry) Validate() error {
|
||||||
|
if p.QueryText == "" {
|
||||||
|
return errors.New("playbook: query_text required")
|
||||||
|
}
|
||||||
|
if p.AnswerID == "" {
|
||||||
|
return errors.New("playbook: answer_id required")
|
||||||
|
}
|
||||||
|
if p.AnswerCorpus == "" {
|
||||||
|
return errors.New("playbook: answer_corpus required")
|
||||||
|
}
|
||||||
|
if p.Score < 0 || p.Score > 1 {
|
||||||
|
return errors.New("playbook: score must be in [0, 1]")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoostFactor returns the multiplier applied to a result's distance
|
||||||
|
// when this playbook entry matches it. Lower is better:
|
||||||
|
//
|
||||||
|
// score = 0 → 1.0 (no boost)
|
||||||
|
// score = 0.5 → 0.75 (mild boost)
|
||||||
|
// score = 1.0 → 0.5 (halve the distance — strong boost)
|
||||||
|
//
|
||||||
|
// Math: 1 - 0.5*score. Capped to [0.5, 1.0] for safety.
|
||||||
|
//
|
||||||
|
// Why halving as the maximum boost: a perfect-confidence playbook
|
||||||
|
// entry shouldn't completely override the base embedding (that
|
||||||
|
// invites runaway feedback loops where one early playbook
|
||||||
|
// dominates forever). Halving is enough to move a mid-rank result
|
||||||
|
// to the top in most cases without erasing the base ranking
|
||||||
|
// signal.
|
||||||
|
func (p PlaybookEntry) BoostFactor() float64 {
|
||||||
|
score := p.Score
|
||||||
|
if score < 0 {
|
||||||
|
score = 0
|
||||||
|
}
|
||||||
|
if score > 1 {
|
||||||
|
score = 1
|
||||||
|
}
|
||||||
|
return 1.0 - 0.5*score
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalMetadata serializes the entry as the JSON RawMessage that
|
||||||
|
// vectord stores per item. Convenience for the recorder.
|
||||||
|
func (p PlaybookEntry) MarshalMetadata() (json.RawMessage, error) {
|
||||||
|
return json.Marshal(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalPlaybookMetadata is the inverse — used when fetching
|
||||||
|
// playbook hits to decode their metadata back into entries.
|
||||||
|
func UnmarshalPlaybookMetadata(raw json.RawMessage) (PlaybookEntry, error) {
|
||||||
|
var e PlaybookEntry
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return e, errors.New("playbook: empty metadata")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &e); err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlaybookEntry stamps RecordedAtNs to now and returns the entry.
|
||||||
|
// Validation happens at storage; this is just construction.
|
||||||
|
//
|
||||||
|
// Role-aware overload is NewPlaybookEntryWithRole — callers that don't
|
||||||
|
// know the role can keep using this constructor and the role gate
|
||||||
|
// will degrade to current (un-gated) behavior.
|
||||||
|
func NewPlaybookEntry(query, answerID, answerCorpus string, score float64, tags []string) PlaybookEntry {
|
||||||
|
return PlaybookEntry{
|
||||||
|
QueryText: query,
|
||||||
|
AnswerID: answerID,
|
||||||
|
AnswerCorpus: answerCorpus,
|
||||||
|
Score: score,
|
||||||
|
RecordedAtNs: time.Now().UnixNano(),
|
||||||
|
Tags: tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlaybookEntryWithRole is the role-aware variant of NewPlaybookEntry.
|
||||||
|
// role should be a stable role token (see roleEqual / role-token notes
|
||||||
|
// in the cross-role gate); empty disables the gate for this entry.
|
||||||
|
func NewPlaybookEntryWithRole(query, role, answerID, answerCorpus string, score float64, tags []string) PlaybookEntry {
|
||||||
|
e := NewPlaybookEntry(query, answerID, answerCorpus, score, tags)
|
||||||
|
e.Role = role
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaybookHit is one similarity-search result from the playbook
|
||||||
|
// corpus, paired with its decoded entry. Distance is the cosine
|
||||||
|
// distance between the current query and this past playbook's
|
||||||
|
// query vector — used by the caller to filter out "too far"
|
||||||
|
// matches via PlaybookMaxDistance.
|
||||||
|
type PlaybookHit struct {
|
||||||
|
PlaybookID string `json:"playbook_id"`
|
||||||
|
Distance float32 `json:"distance"`
|
||||||
|
Entry PlaybookEntry `json:"entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleEqual is the cross-role gate's match function. Returns true when
|
||||||
|
// EITHER side is empty (gate disabled — current behavior preserved) OR
|
||||||
|
// the role tokens compare equal under a normalization that ignores
|
||||||
|
// case + trailing 's' (so "Forklift Operator" matches "forklift
|
||||||
|
// operator" matches "Forklift Operators"). Robust enough for the
|
||||||
|
// staffing-domain plurals seen in fill_events; tighter normalization
|
||||||
|
// (Levenshtein, role-graph) is a separate concern and not blocking
|
||||||
|
// the bleed fix.
|
||||||
|
//
|
||||||
|
// Why this lives near PlaybookEntry: the gate is a property of the
|
||||||
|
// entry/query pair, not of the boost or inject mechanism — both call
|
||||||
|
// sites in this file delegate to the same function so they can't
|
||||||
|
// drift apart.
|
||||||
|
func roleEqual(a, b string) bool {
|
||||||
|
if a == "" || b == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return roleNormalize(a) == roleNormalize(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonPluralSWords is the allowlist of staffing-domain tokens that end
|
||||||
|
// in 's' but are NOT plurals — naïve trailing-'s' stripping would
|
||||||
|
// mangle these into wrong tokens ("Sales" → "Sale", "Logistics" →
|
||||||
|
// "Logistic"). Per scrum review role_gate_v1 (Opus + Kimi + Qwen
|
||||||
|
// convergent finding 2026-04-30): expand this list as new false-
|
||||||
|
// positives surface from production traffic.
|
||||||
|
var nonPluralSWords = map[string]bool{
|
||||||
|
"sales": true,
|
||||||
|
"logistics": true,
|
||||||
|
"operations": true,
|
||||||
|
"facilities": true,
|
||||||
|
"premises": true,
|
||||||
|
"news": true,
|
||||||
|
"physics": true,
|
||||||
|
"economics": true,
|
||||||
|
"mathematics":true,
|
||||||
|
"analytics": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleNormalize lowercases + trims + best-effort-singularizes the
|
||||||
|
// last word so "Forklift Operators" matches "Forklift Operator" but
|
||||||
|
// "Sales Associate" doesn't lose its "s".
|
||||||
|
//
|
||||||
|
// Only the LAST word is plural-checked because staffing-domain role
|
||||||
|
// names follow English head-noun-last conventions ("Sales Associate"
|
||||||
|
// → head is "Associate"; "Forklift Operators" → head is "Operators").
|
||||||
|
//
|
||||||
|
// Singularization rules (in order):
|
||||||
|
// 1. Last word ∈ nonPluralSWords → return as-is (e.g. "Logistics
|
||||||
|
// Coordinator" stays "logistics coordinator").
|
||||||
|
// 2. Last word ends in "ss" → return as-is ("Press Operator" stays;
|
||||||
|
// "Boss" stays).
|
||||||
|
// 3. Last word ends in "es" after "x"/"s"/"ch"/"sh" → strip "es"
|
||||||
|
// ("Boxes" → "Box", "Dishes" → "Dish").
|
||||||
|
// 4. Last word ends in "s" → strip "s" ("Operators" → "Operator").
|
||||||
|
// 5. Otherwise return as-is.
|
||||||
|
func roleNormalize(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Find the last word.
|
||||||
|
lastSpace := strings.LastIndex(s, " ")
|
||||||
|
prefix, last := "", s
|
||||||
|
if lastSpace >= 0 {
|
||||||
|
prefix, last = s[:lastSpace+1], s[lastSpace+1:]
|
||||||
|
}
|
||||||
|
if nonPluralSWords[last] {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(last, "ss") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
n := len(last)
|
||||||
|
if n >= 3 && last[n-1] == 's' && last[n-2] == 'e' &&
|
||||||
|
(last[n-3] == 'x' || last[n-3] == 's' || last[n-3] == 'h') {
|
||||||
|
return prefix + last[:n-2]
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(last, "s") {
|
||||||
|
return prefix + last[:n-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectPlaybookMisses appends synthetic Results for playbook hits
|
||||||
|
// whose (AnswerCorpus, AnswerID) doesn't already appear in results.
|
||||||
|
// This is "Shape B" from the doc comment at the top of this file:
|
||||||
|
// the v0 boost-only stance (ApplyPlaybookBoost) can't promote a
|
||||||
|
// recorded answer that wasn't already in the regular retrieval's
|
||||||
|
// top-K. Paraphrase queries broke this — different embedding ⇒
|
||||||
|
// different top-K ⇒ recorded answer drops out ⇒ no boost can save
|
||||||
|
// it. Reality test playbook_lift_002 showed 0/2 paraphrase top-1
|
||||||
|
// lifts because of exactly that.
|
||||||
|
//
|
||||||
|
// Synthetic distance = playbook_hit_distance × BoostFactor — same
|
||||||
|
// formula as ApplyPlaybookBoost, applied to the playbook hit's own
|
||||||
|
// distance instead of a result's. Lower playbook hit distance
|
||||||
|
// (current query is similar to recorded query) AND higher score
|
||||||
|
// (recorded outcome was strong) push the injection toward top-1.
|
||||||
|
//
|
||||||
|
// fetchPlaybookHits has already filtered hits to those within
|
||||||
|
// DefaultPlaybookMaxDistance (0.5), so injected results land in the
|
||||||
|
// same distance range as regular retrieval — they don't dominate
|
||||||
|
// top-K from out-of-distribution playbooks.
|
||||||
|
//
|
||||||
|
// Returns the (possibly extended) results slice and how many synthetic
|
||||||
|
// rows were appended. Caller MUST re-sort + truncate to K afterwards.
|
||||||
|
//
|
||||||
|
// maxInjectDist filters which hits qualify for injection — hits whose
|
||||||
|
// playbook-corpus cosine distance exceeds it are skipped (the boost
|
||||||
|
// path may still re-rank them in place). Pass 0 (or any non-positive
|
||||||
|
// value) to use DefaultPlaybookMaxInjectDistance.
|
||||||
|
//
|
||||||
|
// gate is an optional approval callback called once per CANDIDATE
|
||||||
|
// (post-distance-filter, post-dedup) before injection. Returning
|
||||||
|
// false rejects that candidate. Use nil for the historical "all
|
||||||
|
// distance-eligible candidates inject" behavior.
|
||||||
|
//
|
||||||
|
// Multi-coord run #008's judge re-rating proved that distance + LLM
|
||||||
|
// rating disagree often enough to matter (Q3 crane: dist 0.23 looks
|
||||||
|
// confident, judge says 1/5 = irrelevant). Lift-suite tail issues
|
||||||
|
// (Q6↔Q7 swap, Q9/Q15 paraphrase drift) are exactly this shape —
|
||||||
|
// embedding-tight but wrong-domain. The gate parameter lets callers
|
||||||
|
// route those candidates through a judge before the inject lands.
|
||||||
|
//
|
||||||
|
// query is the current search's query text — passed to the gate so
|
||||||
|
// it can score (query, candidate) pairs without re-deriving from
|
||||||
|
// SearchRequest. Empty when the caller doesn't have it (gate
|
||||||
|
// implementations should treat empty query as "skip judge, allow").
|
||||||
|
//
|
||||||
|
// queryRole, when non-empty, gates injection on role match: a playbook
|
||||||
|
// hit whose Entry.Role is set and DIFFERENT from queryRole is rejected
|
||||||
|
// before the judge gate runs. Closes the same-client+city cross-role
|
||||||
|
// bleed (real_001 finding) where the inject distance threshold (0.20)
|
||||||
|
// wasn't tight enough to prevent Forklift-Operator playbook entries
|
||||||
|
// from injecting on CNC-Operator queries that happened to embed close
|
||||||
|
// (same city + client + count-token + time-token dominate cosine).
|
||||||
|
// Empty queryRole OR empty hit.Entry.Role = no gate (preserves
|
||||||
|
// behavior for callers that don't supply role).
|
||||||
|
func InjectPlaybookMisses(query, queryRole string, results []Result, hits []PlaybookHit, maxInjectDist float32, gate InjectGate) ([]Result, int) {
|
||||||
|
if len(hits) == 0 {
|
||||||
|
return results, 0
|
||||||
|
}
|
||||||
|
if maxInjectDist <= 0 {
|
||||||
|
maxInjectDist = float32(DefaultPlaybookMaxInjectDistance)
|
||||||
|
}
|
||||||
|
present := make(map[string]bool, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
present[r.Corpus+"|"+r.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each (corpus, id) NOT in results, keep the playbook hit
|
||||||
|
// with the largest boost (lowest BoostFactor = highest score).
|
||||||
|
// Multiple hits to the same answer collapse to one injection.
|
||||||
|
bestForKey := make(map[string]PlaybookHit)
|
||||||
|
for _, h := range hits {
|
||||||
|
// Inject-specific tighter threshold (boost path's threshold is
|
||||||
|
// looser; this prevents cross-pollination of wrong-domain
|
||||||
|
// answers into queries whose text happens to fall within
|
||||||
|
// boost-distance of an unrelated recording).
|
||||||
|
if h.Distance > maxInjectDist {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Cross-role gate (real_001 bleed fix). Reject before the
|
||||||
|
// judge gate so we don't burn a slow LLM call to immediately
|
||||||
|
// reject the candidate on a structural mismatch.
|
||||||
|
if !roleEqual(queryRole, h.Entry.Role) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := h.Entry.AnswerCorpus + "|" + h.Entry.AnswerID
|
||||||
|
if present[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing, ok := bestForKey[key]; !ok || h.Entry.BoostFactor() < existing.Entry.BoostFactor() {
|
||||||
|
bestForKey[key] = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
injected := 0
|
||||||
|
for _, h := range bestForKey {
|
||||||
|
// Judge gate (per OPEN item #1, closed by this commit):
|
||||||
|
// post-distance-filter, ask the gate whether the candidate
|
||||||
|
// actually fits the current query before letting it inject.
|
||||||
|
// Closes the lift-suite tail issues where embedding said
|
||||||
|
// "tight" but a judge said "wrong domain."
|
||||||
|
if gate != nil && !gate.Approve(query, h) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
injectedDist := h.Distance * float32(h.Entry.BoostFactor())
|
||||||
|
// Synthesize metadata that flags the injection so callers
|
||||||
|
// (driver/UI/observer) can distinguish "regular retrieval"
|
||||||
|
// from "playbook injection." Production consumers needing
|
||||||
|
// the actual worker metadata can fetch from vectord by
|
||||||
|
// (Corpus, ID) — synthetic results carry only provenance.
|
||||||
|
meta, _ := json.Marshal(map[string]any{
|
||||||
|
"playbook_injected": true,
|
||||||
|
"playbook_id": h.PlaybookID,
|
||||||
|
"playbook_score": h.Entry.Score,
|
||||||
|
"playbook_query_text": h.Entry.QueryText,
|
||||||
|
"playbook_recorded_at_ns": h.Entry.RecordedAtNs,
|
||||||
|
"playbook_hit_distance": h.Distance,
|
||||||
|
})
|
||||||
|
results = append(results, Result{
|
||||||
|
ID: h.Entry.AnswerID,
|
||||||
|
Corpus: h.Entry.AnswerCorpus,
|
||||||
|
Distance: injectedDist,
|
||||||
|
Metadata: meta,
|
||||||
|
})
|
||||||
|
injected++
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, injected
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectGate is the optional approval callback for Shape B inject.
|
||||||
|
// Called once per candidate (after distance filter, after dedup).
|
||||||
|
// Returning false rejects that candidate. Implementations:
|
||||||
|
// - LLMJudgeGate (this package, see judge.go): Ollama LLM rates the
|
||||||
|
// (query, candidate) pair against a 1-5 rubric.
|
||||||
|
// - InjectGateFunc (this package): zero-deps adapter for arbitrary
|
||||||
|
// caller logic — useful in tests + when callers want non-LLM
|
||||||
|
// gating (e.g. metadata-only filters).
|
||||||
|
//
|
||||||
|
// nil InjectGate = pre-judge-gating behavior (all distance-eligible
|
||||||
|
// candidates inject); preserves backward compatibility.
|
||||||
|
type InjectGate interface {
|
||||||
|
Approve(query string, hit PlaybookHit) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectGateFunc adapts a plain function to the InjectGate interface.
|
||||||
|
// Used heavily in tests; production callers usually use LLMJudgeGate.
|
||||||
|
type InjectGateFunc func(query string, hit PlaybookHit) bool
|
||||||
|
|
||||||
|
// Approve makes InjectGateFunc satisfy InjectGate.
|
||||||
|
func (f InjectGateFunc) Approve(q string, h PlaybookHit) bool { return f(q, h) }
|
||||||
|
|
||||||
|
// ApplyPlaybookBoost re-ranks results in place using matched
|
||||||
|
// playbook hits. For each hit whose (AnswerID, AnswerCorpus)
|
||||||
|
// matches a result, multiply that result's distance by the hit's
|
||||||
|
// BoostFactor. If multiple hits match the same result, the highest-
|
||||||
|
// score one wins (greatest reduction in distance).
|
||||||
|
//
|
||||||
|
// queryRole, when non-empty, gates the boost on role match — same
|
||||||
|
// semantics as InjectPlaybookMisses' queryRole. Defense in depth:
|
||||||
|
// even if a recorded entry's answer happens to surface in the
|
||||||
|
// regular retrieve top-K for a different-role query, role-mismatch
|
||||||
|
// shouldn't fire a boost. Empty role on either side disables the gate.
|
||||||
|
//
|
||||||
|
// After applying boosts, results are re-sorted ascending by
|
||||||
|
// distance.
|
||||||
|
//
|
||||||
|
// Returns the number of distinct results that received a boost.
|
||||||
|
// Callers can log this as a signal of "how much the playbook
|
||||||
|
// influenced this query."
|
||||||
|
func ApplyPlaybookBoost(queryRole string, results []Result, hits []PlaybookHit) int {
|
||||||
|
if len(hits) == 0 || len(results) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each result, find the hit with the lowest BoostFactor
|
||||||
|
// (= largest boost = highest score, since BoostFactor is
|
||||||
|
// 1-0.5*score and we minimize).
|
||||||
|
bestBoost := make(map[int]float64, len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
for _, h := range hits {
|
||||||
|
if h.Entry.AnswerID != r.ID || h.Entry.AnswerCorpus != r.Corpus {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Cross-role gate (real_001 bleed fix). Same semantics as
|
||||||
|
// InjectPlaybookMisses — see comment there.
|
||||||
|
if !roleEqual(queryRole, h.Entry.Role) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bf := h.Entry.BoostFactor()
|
||||||
|
if cur, ok := bestBoost[i]; !ok || bf < cur {
|
||||||
|
bestBoost[i] = bf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, bf := range bestBoost {
|
||||||
|
results[i].Distance = float32(float64(results[i].Distance) * bf)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(results, func(i, j int) bool {
|
||||||
|
return results[i].Distance < results[j].Distance
|
||||||
|
})
|
||||||
|
|
||||||
|
return len(bestBoost)
|
||||||
|
}
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlaybookEntry_Validate(t *testing.T) {
|
||||||
|
good := PlaybookEntry{
|
||||||
|
QueryText: "x", AnswerID: "y", AnswerCorpus: "z", Score: 0.5,
|
||||||
|
}
|
||||||
|
if err := good.Validate(); err != nil {
|
||||||
|
t.Errorf("good entry should validate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
entry PlaybookEntry
|
||||||
|
}{
|
||||||
|
{"empty query", PlaybookEntry{AnswerID: "y", AnswerCorpus: "z", Score: 0.5}},
|
||||||
|
{"empty answer id", PlaybookEntry{QueryText: "x", AnswerCorpus: "z", Score: 0.5}},
|
||||||
|
{"empty corpus", PlaybookEntry{QueryText: "x", AnswerID: "y", Score: 0.5}},
|
||||||
|
{"score too high", PlaybookEntry{QueryText: "x", AnswerID: "y", AnswerCorpus: "z", Score: 1.5}},
|
||||||
|
{"score negative", PlaybookEntry{QueryText: "x", AnswerID: "y", AnswerCorpus: "z", Score: -0.1}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.entry.Validate(); err == nil {
|
||||||
|
t.Errorf("%s: expected validation error, got nil", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaybookEntry_BoostFactor(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
score float64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{0.0, 1.0},
|
||||||
|
{0.5, 0.75},
|
||||||
|
{1.0, 0.5},
|
||||||
|
{-0.1, 1.0}, // clamped
|
||||||
|
{1.5, 0.5}, // clamped
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := PlaybookEntry{Score: c.score}.BoostFactor()
|
||||||
|
if abs(got-c.want) > 1e-9 {
|
||||||
|
t.Errorf("BoostFactor(score=%.2f): want %.4f, got %.4f", c.score, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPlaybookBoost_NoHitsLeaveResultsAlone(t *testing.T) {
|
||||||
|
results := []Result{
|
||||||
|
{ID: "a", Distance: 0.1, Corpus: "x"},
|
||||||
|
{ID: "b", Distance: 0.2, Corpus: "x"},
|
||||||
|
}
|
||||||
|
n := ApplyPlaybookBoost("", results, nil)
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("expected 0 boosted, got %d", n)
|
||||||
|
}
|
||||||
|
if results[0].ID != "a" || results[1].ID != "b" {
|
||||||
|
t.Errorf("results reordered without hits: %v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPlaybookBoost_BoostMovesResultUp(t *testing.T) {
|
||||||
|
// Initial: a (0.10) beats b (0.20) beats c (0.30).
|
||||||
|
// Playbook says (answer=c, score=1.0) should be boosted → c's
|
||||||
|
// distance becomes 0.30 * 0.5 = 0.15. New ordering: a, c, b.
|
||||||
|
results := []Result{
|
||||||
|
{ID: "a", Distance: 0.10, Corpus: "x"},
|
||||||
|
{ID: "b", Distance: 0.20, Corpus: "x"},
|
||||||
|
{ID: "c", Distance: 0.30, Corpus: "x"},
|
||||||
|
}
|
||||||
|
hits := []PlaybookHit{
|
||||||
|
{PlaybookID: "p1", Distance: 0.05, Entry: PlaybookEntry{
|
||||||
|
AnswerID: "c", AnswerCorpus: "x", Score: 1.0,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
n := ApplyPlaybookBoost("", results, hits)
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("expected 1 boosted, got %d", n)
|
||||||
|
}
|
||||||
|
if results[0].ID != "a" || results[1].ID != "c" || results[2].ID != "b" {
|
||||||
|
t.Errorf("expected order a,c,b after boost; got %v", idsOf(results))
|
||||||
|
}
|
||||||
|
if abs(float64(results[1].Distance)-0.15) > 1e-6 {
|
||||||
|
t.Errorf("expected c distance 0.15 after boost; got %.4f", results[1].Distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPlaybookBoost_HighestScoreWinsForSameAnswer(t *testing.T) {
|
||||||
|
results := []Result{
|
||||||
|
{ID: "a", Distance: 0.30, Corpus: "x"},
|
||||||
|
}
|
||||||
|
// Two playbook hits both pointing at "a". Score=0.4 (weak boost)
|
||||||
|
// + Score=0.9 (strong boost). Strong should win — distance gets
|
||||||
|
// multiplied by 1-0.5*0.9 = 0.55, not by 1-0.5*0.4 = 0.80.
|
||||||
|
hits := []PlaybookHit{
|
||||||
|
{PlaybookID: "p_weak", Distance: 0.05, Entry: PlaybookEntry{
|
||||||
|
AnswerID: "a", AnswerCorpus: "x", Score: 0.4,
|
||||||
|
}},
|
||||||
|
{PlaybookID: "p_strong", Distance: 0.05, Entry: PlaybookEntry{
|
||||||
|
AnswerID: "a", AnswerCorpus: "x", Score: 0.9,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
ApplyPlaybookBoost("", results, hits)
|
||||||
|
wantDist := 0.30 * 0.55
|
||||||
|
if abs(float64(results[0].Distance)-wantDist) > 1e-6 {
|
||||||
|
t.Errorf("strong-score boost should win: want %.4f, got %.4f", wantDist, results[0].Distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPlaybookBoost_CorpusAttributionRespected(t *testing.T) {
|
||||||
|
// Playbook references answer_id="a" in corpus="x".
|
||||||
|
// Results have answer_id="a" in corpus="y" — DIFFERENT corpus.
|
||||||
|
// Boost should NOT apply; the (id, corpus) tuple is the join key,
|
||||||
|
// not just id (otherwise different-corpus collisions would create
|
||||||
|
// false positives).
|
||||||
|
results := []Result{
|
||||||
|
{ID: "a", Distance: 0.30, Corpus: "y"},
|
||||||
|
}
|
||||||
|
hits := []PlaybookHit{
|
||||||
|
{PlaybookID: "p1", Distance: 0.05, Entry: PlaybookEntry{
|
||||||
|
AnswerID: "a", AnswerCorpus: "x", Score: 1.0,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
n := ApplyPlaybookBoost("", results, hits)
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("cross-corpus collision should not boost: got %d", n)
|
||||||
|
}
|
||||||
|
if abs(float64(results[0].Distance)-0.30) > 1e-6 {
|
||||||
|
// 1e-6 tolerance accounts for float32→float64 conversion;
|
||||||
|
// the assertion that matters is "unchanged from input."
|
||||||
|
t.Errorf("distance should be unchanged: got %.6f", results[0].Distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaybookEntry_RoundTripJSON(t *testing.T) {
|
||||||
|
e := NewPlaybookEntry("forklift query", "w-12345", "workers", 0.85, []string{"chicago", "verified"})
|
||||||
|
raw, err := e.MarshalMetadata()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
got, err := UnmarshalPlaybookMetadata(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if got.QueryText != e.QueryText || got.AnswerID != e.AnswerID ||
|
||||||
|
got.AnswerCorpus != e.AnswerCorpus || got.Score != e.Score {
|
||||||
|
t.Errorf("round-trip mismatch: want %+v, got %+v", e, got)
|
||||||
|
}
|
||||||
|
if len(got.Tags) != 2 || got.Tags[0] != "chicago" {
|
||||||
|
t.Errorf("tags lost in round-trip: %v", got.Tags)
|
||||||
|
}
|
||||||
|
if got.RecordedAtNs == 0 {
|
||||||
|
t.Error("RecordedAtNs not set by NewPlaybookEntry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalPlaybookMetadata_RejectsEmpty(t *testing.T) {
|
||||||
|
if _, err := UnmarshalPlaybookMetadata(json.RawMessage{}); err == nil {
|
||||||
|
t.Error("empty metadata should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInjectPlaybookMisses_AddsMissingAnswers locks Shape B's primary
|
||||||
|
// claim: when a playbook hit's answer isn't already in regular
|
||||||
|
// retrieval results, InjectPlaybookMisses appends a synthetic Result
|
||||||
|
// for it. Reality test playbook_lift_002 surfaced 0/2 paraphrase
|
||||||
|
// recoveries because the v0 boost-only stance couldn't promote
|
||||||
|
// answers that dropped out of the paraphrase's top-K.
|
||||||
|
func TestInjectPlaybookMisses_AddsMissingAnswers(t *testing.T) {
|
||||||
|
results := []Result{
|
||||||
|
{ID: "w-1", Corpus: "workers", Distance: 0.30},
|
||||||
|
{ID: "w-2", Corpus: "workers", Distance: 0.35},
|
||||||
|
}
|
||||||
|
hits := []PlaybookHit{
|
||||||
|
{
|
||||||
|
PlaybookID: "pb-x",
|
||||||
|
Distance: 0.20, // current query is close to recorded query
|
||||||
|
Entry: PlaybookEntry{
|
||||||
|
QueryText: "recorded query",
|
||||||
|
AnswerID: "w-99", // NOT in results
|
||||||
|
AnswerCorpus: "workers",
|
||||||
|
Score: 1.0, // strong outcome → boost factor 0.5
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, injected := InjectPlaybookMisses("test query", "", results, hits, 0, nil)
|
||||||
|
if injected != 1 {
|
||||||
|
t.Fatalf("expected 1 injected, got %d", injected)
|
||||||
|
}
|
||||||
|
if len(out) != 3 {
|
||||||
|
t.Fatalf("expected len=3, got %d (%v)", len(out), idsOf(out))
|
||||||
|
}
|
||||||
|
// The injected result should be findable + carry the playbook
|
||||||
|
// provenance metadata flag.
|
||||||
|
var injectedResult *Result
|
||||||
|
for i := range out {
|
||||||
|
if out[i].ID == "w-99" {
|
||||||
|
injectedResult = &out[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if injectedResult == nil {
|
||||||
|
t.Fatal("w-99 not present in output")
|
||||||
|
}
|
||||||
|
// distance = 0.20 * 0.5 = 0.10 → near-top after caller re-sorts
|
||||||
|
if injectedResult.Distance < 0.099 || injectedResult.Distance > 0.101 {
|
||||||
|
t.Errorf("expected injected distance ~0.10, got %f", injectedResult.Distance)
|
||||||
|
}
|
||||||
|
var meta map[string]any
|
||||||
|
if err := json.Unmarshal(injectedResult.Metadata, &meta); err != nil {
|
||||||
|
t.Fatalf("decode meta: %v", err)
|
||||||
|
}
|
||||||
|
if v, _ := meta["playbook_injected"].(bool); !v {
|
||||||
|
t.Errorf("expected playbook_injected=true marker, got %v", meta)
|
||||||
|
}
|
||||||
|
if v, _ := meta["playbook_query_text"].(string); v != "recorded query" {
|
||||||
|
t.Errorf("expected recorded query in meta, got %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInjectPlaybookMisses_SkipsAnswersAlreadyPresent locks the
|
||||||
|
// boost-only-when-present property. If a playbook hit's answer is
|
||||||
|
// ALREADY in results, we don't duplicate-inject — ApplyPlaybookBoost
|
||||||
|
// has handled that case via in-place re-rank.
|
||||||
|
func TestInjectPlaybookMisses_SkipsAnswersAlreadyPresent(t *testing.T) {
|
||||||
|
results := []Result{
|
||||||
|
{ID: "w-1", Corpus: "workers", Distance: 0.30},
|
||||||
|
{ID: "w-99", Corpus: "workers", Distance: 0.40}, // ALREADY HERE
|
||||||
|
}
|
||||||
|
hits := []PlaybookHit{
|
||||||
|
{
|
||||||
|
PlaybookID: "pb-x",
|
||||||
|
Distance: 0.20,
|
||||||
|
Entry: PlaybookEntry{
|
||||||
|
QueryText: "x", AnswerID: "w-99", AnswerCorpus: "workers", Score: 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, injected := InjectPlaybookMisses("test query", "", results, hits, 0, nil)
|
||||||
|
if injected != 0 {
|
||||||
|
t.Errorf("expected 0 injected (answer already present), got %d", injected)
|
||||||
|
}
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Errorf("expected results unchanged at len=2, got %d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInjectPlaybookMisses_DedupesPerAnswer locks: multiple playbook
|
||||||
|
// hits all pointing to the same missing answer collapse to ONE
|
||||||
|
// injection (the highest-scoring hit wins).
|
||||||
|
func TestInjectPlaybookMisses_DedupesPerAnswer(t *testing.T) {
|
||||||
|
results := []Result{{ID: "w-1", Corpus: "workers", Distance: 0.30}}
|
||||||
|
hits := []PlaybookHit{
|
||||||
|
{
|
||||||
|
PlaybookID: "pb-low",
|
||||||
|
Distance: 0.30,
|
||||||
|
Entry: PlaybookEntry{QueryText: "q1", AnswerID: "w-99", AnswerCorpus: "workers", Score: 0.4},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
PlaybookID: "pb-high",
|
||||||
|
Distance: 0.30,
|
||||||
|
Entry: PlaybookEntry{QueryText: "q2", AnswerID: "w-99", AnswerCorpus: "workers", Score: 1.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, injected := InjectPlaybookMisses("test query", "", results, hits, 0.5, nil) // explicit loose threshold so 0.30 hits qualify
|
||||||
|
if injected != 1 {
|
||||||
|
t.Errorf("expected 1 injection (deduped), got %d", injected)
|
||||||
|
}
|
||||||
|
// Score=1.0 (the high one) wins → boost factor 0.5 → distance 0.15
|
||||||
|
for _, r := range out {
|
||||||
|
if r.ID == "w-99" {
|
||||||
|
if r.Distance < 0.149 || r.Distance > 0.151 {
|
||||||
|
t.Errorf("expected distance from highest-score hit (~0.15), got %f", r.Distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInjectPlaybookMisses_GateRejectsCandidate locks the judge-gate
|
||||||
|
// path (OPEN item #1, closed by this commit). When the InjectGate
|
||||||
|
// returns false on a candidate, the candidate is skipped — even if
|
||||||
|
// distance would otherwise allow it. Closes the lift-suite tail
|
||||||
|
// issues where embedding said "tight" but a judge said "wrong domain."
|
||||||
|
func TestInjectPlaybookMisses_GateRejectsCandidate(t *testing.T) {
|
||||||
|
results := []Result{{ID: "w-1", Corpus: "workers", Distance: 0.30}}
|
||||||
|
hits := []PlaybookHit{
|
||||||
|
{
|
||||||
|
PlaybookID: "pb-x",
|
||||||
|
Distance: 0.10, // tight in cosine — would inject without gate
|
||||||
|
Entry: PlaybookEntry{
|
||||||
|
QueryText: "recorded crane operator query",
|
||||||
|
AnswerID: "w-99", AnswerCorpus: "workers", Score: 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rejectAll := InjectGateFunc(func(string, PlaybookHit) bool { return false })
|
||||||
|
out, injected := InjectPlaybookMisses("forklift loader query", "", results, hits, 0, rejectAll)
|
||||||
|
if injected != 0 {
|
||||||
|
t.Errorf("rejectAll gate should skip injection, got %d injected", injected)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Errorf("results should be unchanged at len=1, got %d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInjectPlaybookMisses_GateApprovesCandidate locks the
|
||||||
|
// always-approve gate path: behavior matches nil-gate (current
|
||||||
|
// distance-only filter). Useful for tests that want to assert
|
||||||
|
// "judge-gate API is wired" without an actual decision.
|
||||||
|
func TestInjectPlaybookMisses_GateApprovesCandidate(t *testing.T) {
|
||||||
|
results := []Result{{ID: "w-1", Corpus: "workers", Distance: 0.30}}
|
||||||
|
hits := []PlaybookHit{
|
||||||
|
{
|
||||||
|
PlaybookID: "pb-x",
|
||||||
|
Distance: 0.10,
|
||||||
|
Entry: PlaybookEntry{
|
||||||
|
QueryText: "x", AnswerID: "w-99", AnswerCorpus: "workers", Score: 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
approveAll := InjectGateFunc(func(string, PlaybookHit) bool { return true })
|
||||||
|
out, injected := InjectPlaybookMisses("test query", "", results, hits, 0, approveAll)
|
||||||
|
if injected != 1 {
|
||||||
|
t.Errorf("approveAll gate should inject, got %d", injected)
|
||||||
|
}
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Errorf("results should grow to 2, got %d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInjectPlaybookMisses_GateSeesCorrectQuery locks the gate's
|
||||||
|
// query+hit visibility — the gate must receive the CURRENT search's
|
||||||
|
// query (not the recorded one) so it can judge the (current_query,
|
||||||
|
// candidate) pair. The recorded query lives on hit.Entry.QueryText.
|
||||||
|
func TestInjectPlaybookMisses_GateSeesCorrectQuery(t *testing.T) {
|
||||||
|
results := []Result{{ID: "w-1", Corpus: "workers", Distance: 0.30}}
|
||||||
|
hits := []PlaybookHit{
|
||||||
|
{
|
||||||
|
PlaybookID: "pb-x",
|
||||||
|
Distance: 0.10,
|
||||||
|
Entry: PlaybookEntry{
|
||||||
|
QueryText: "RECORDED",
|
||||||
|
AnswerID: "w-99", AnswerCorpus: "workers", Score: 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var seenQuery, seenRecordedQuery string
|
||||||
|
gate := InjectGateFunc(func(q string, h PlaybookHit) bool {
|
||||||
|
seenQuery = q
|
||||||
|
seenRecordedQuery = h.Entry.QueryText
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
_, _ = InjectPlaybookMisses("CURRENT", "", results, hits, 0, gate)
|
||||||
|
if seenQuery != "CURRENT" {
|
||||||
|
t.Errorf("gate received query=%q, want CURRENT", seenQuery)
|
||||||
|
}
|
||||||
|
if seenRecordedQuery != "RECORDED" {
|
||||||
|
t.Errorf("gate received recorded=%q, want RECORDED", seenRecordedQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInjectPlaybookMisses_RespectsInjectThreshold locks the
|
||||||
|
// cross-pollination defense added after run #003: hits whose playbook
|
||||||
|
// distance exceeds the inject threshold are skipped, preventing the
|
||||||
|
// "OSHA-30 forklift" recording from surfacing as warm top-1 for an
|
||||||
|
// unrelated dental-hygienist query just because their text vectors
|
||||||
|
// happened to fall within boost-threshold (0.5).
|
||||||
|
func TestInjectPlaybookMisses_RespectsInjectThreshold(t *testing.T) {
|
||||||
|
results := []Result{{ID: "w-1", Corpus: "workers", Distance: 0.30}}
|
||||||
|
// Two hits: one within tight inject threshold, one beyond it but
|
||||||
|
// within boost threshold. Only the tight one should inject.
|
||||||
|
hits := []PlaybookHit{
|
||||||
|
{
|
||||||
|
PlaybookID: "tight",
|
||||||
|
Distance: 0.10, // within inject (true paraphrase territory)
|
||||||
|
Entry: PlaybookEntry{QueryText: "q1", AnswerID: "w-tight", AnswerCorpus: "workers", Score: 1.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
PlaybookID: "loose",
|
||||||
|
Distance: 0.40, // boost-eligible but inject-rejected
|
||||||
|
Entry: PlaybookEntry{QueryText: "q2", AnswerID: "w-loose", AnswerCorpus: "workers", Score: 1.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Default threshold (0 → DefaultPlaybookMaxInjectDistance = 0.20)
|
||||||
|
out, injected := InjectPlaybookMisses("test query", "", results, hits, 0, nil)
|
||||||
|
if injected != 1 {
|
||||||
|
t.Errorf("expected 1 injection (only the tight hit qualifies), got %d", injected)
|
||||||
|
}
|
||||||
|
gotTight := false
|
||||||
|
for _, r := range out {
|
||||||
|
if r.ID == "w-tight" {
|
||||||
|
gotTight = true
|
||||||
|
}
|
||||||
|
if r.ID == "w-loose" {
|
||||||
|
t.Errorf("loose hit (distance > inject threshold) was injected anyway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gotTight {
|
||||||
|
t.Error("tight hit should have been injected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInjectPlaybookMisses_EmptyHits is a fast-path no-op check.
|
||||||
|
func TestInjectPlaybookMisses_EmptyHits(t *testing.T) {
|
||||||
|
results := []Result{{ID: "w-1", Corpus: "workers", Distance: 0.30}}
|
||||||
|
out, injected := InjectPlaybookMisses("test query", "", results, nil, 0, nil)
|
||||||
|
if injected != 0 {
|
||||||
|
t.Errorf("expected 0 injection, got %d", injected)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Errorf("results should be unchanged, got len=%d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-role gate tests — reproduce the real_001 bleed scenario at
|
||||||
|
// the unit-test level so regressions can't ship without notice.
|
||||||
|
//
|
||||||
|
// real_001 scenario: Q#2 records playbook entry for e-6193 fitting
|
||||||
|
// "Forklift Operator at Beacon Freight Detroit MI". Q#10 (CNC Operator
|
||||||
|
// at same client + city) embeds within 0.135 cosine of Q#2's
|
||||||
|
// playbook entry — well inside the 0.20 inject threshold. Without the
|
||||||
|
// role gate, e-6193 injects on Q#10 at warm-top-1, demoting the
|
||||||
|
// cold-pass-correct w-3759.
|
||||||
|
|
||||||
|
func TestInjectPlaybookMisses_RoleGateRejectsCrossRole(t *testing.T) {
|
||||||
|
results := []Result{{ID: "w-3759", Corpus: "workers", Distance: 0.50}}
|
||||||
|
hits := []PlaybookHit{{
|
||||||
|
PlaybookID: "pb-q2",
|
||||||
|
Distance: 0.135, // tight; well within DefaultPlaybookMaxInjectDistance
|
||||||
|
Entry: PlaybookEntry{
|
||||||
|
QueryText: "Need 1 Forklift Operator in Detroit MI ...",
|
||||||
|
Role: "Forklift Operator",
|
||||||
|
AnswerID: "e-6193",
|
||||||
|
AnswerCorpus: "ethereal_workers",
|
||||||
|
Score: 1.0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
out, injected := InjectPlaybookMisses(
|
||||||
|
"Need 1 CNC Operator in Detroit MI ...",
|
||||||
|
"CNC Operator", // queryRole differs
|
||||||
|
results, hits, 0, nil,
|
||||||
|
)
|
||||||
|
if injected != 0 {
|
||||||
|
t.Errorf("expected 0 cross-role injections, got %d", injected)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Errorf("results should be unchanged when gate rejects, got len=%d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectPlaybookMisses_RoleGateAllowsSameRole(t *testing.T) {
|
||||||
|
results := []Result{{ID: "e-5617", Corpus: "ethereal_workers", Distance: 0.50}}
|
||||||
|
hits := []PlaybookHit{{
|
||||||
|
PlaybookID: "pb-q2",
|
||||||
|
Distance: 0.135,
|
||||||
|
Entry: PlaybookEntry{
|
||||||
|
QueryText: "Need 1 Forklift Operator in Detroit MI ...",
|
||||||
|
Role: "Forklift Operator",
|
||||||
|
AnswerID: "e-6193",
|
||||||
|
AnswerCorpus: "ethereal_workers",
|
||||||
|
Score: 1.0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
out, injected := InjectPlaybookMisses(
|
||||||
|
"Need 2 Forklift Operators in Detroit MI ...",
|
||||||
|
"Forklift Operators", // plural — roleEqual normalizes to match
|
||||||
|
results, hits, 0, nil,
|
||||||
|
)
|
||||||
|
if injected != 1 {
|
||||||
|
t.Errorf("expected 1 same-role injection, got %d", injected)
|
||||||
|
}
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Errorf("results should be extended with the injection, got len=%d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectPlaybookMisses_RoleGateBackwardCompat(t *testing.T) {
|
||||||
|
// Empty role on either side disables the gate — preserves
|
||||||
|
// behavior for callers (lift suite, etc.) that don't supply role.
|
||||||
|
results := []Result{{ID: "w-3759", Corpus: "workers", Distance: 0.50}}
|
||||||
|
hits := []PlaybookHit{{
|
||||||
|
PlaybookID: "pb-q2",
|
||||||
|
Distance: 0.135,
|
||||||
|
Entry: PlaybookEntry{
|
||||||
|
QueryText: "...",
|
||||||
|
Role: "Forklift Operator", // entry has role
|
||||||
|
AnswerID: "e-6193",
|
||||||
|
AnswerCorpus: "ethereal_workers",
|
||||||
|
Score: 1.0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
// Caller didn't pass queryRole — gate must NOT fire.
|
||||||
|
_, injected := InjectPlaybookMisses("...", "", results, hits, 0, nil)
|
||||||
|
if injected != 1 {
|
||||||
|
t.Errorf("expected gate disabled when queryRole empty (injected=1), got %d", injected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverse: queryRole set but entry has no role.
|
||||||
|
hits[0].Entry.Role = ""
|
||||||
|
_, injected = InjectPlaybookMisses("...", "Forklift Operator", results, hits, 0, nil)
|
||||||
|
if injected != 1 {
|
||||||
|
t.Errorf("expected gate disabled when entry.Role empty (injected=1), got %d", injected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPlaybookBoost_RoleGateRejectsCrossRole(t *testing.T) {
|
||||||
|
results := []Result{
|
||||||
|
{ID: "w-3759", Corpus: "workers", Distance: 0.50},
|
||||||
|
{ID: "e-6193", Corpus: "ethereal_workers", Distance: 0.55},
|
||||||
|
}
|
||||||
|
hits := []PlaybookHit{{
|
||||||
|
PlaybookID: "pb-q2",
|
||||||
|
Distance: 0.18,
|
||||||
|
Entry: PlaybookEntry{
|
||||||
|
QueryText: "Forklift Operator query",
|
||||||
|
Role: "Forklift Operator",
|
||||||
|
AnswerID: "e-6193",
|
||||||
|
AnswerCorpus: "ethereal_workers",
|
||||||
|
Score: 1.0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
// Different-role query → boost must NOT fire on e-6193 even though
|
||||||
|
// it's in the result set.
|
||||||
|
n := ApplyPlaybookBoost("CNC Operator", results, hits)
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("expected 0 cross-role boosts, got %d", n)
|
||||||
|
}
|
||||||
|
if results[1].Distance != 0.55 {
|
||||||
|
t.Errorf("e-6193 distance should be unchanged (0.55), got %v", results[1].Distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleEqual_PluralAndCase(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"Forklift Operator", "forklift operator", true},
|
||||||
|
{"Forklift Operator", "Forklift Operators", true}, // plural
|
||||||
|
{"CNC Operator", "Forklift Operator", false},
|
||||||
|
{"Pickers", "Picker", true},
|
||||||
|
{"", "Forklift Operator", true}, // gate disabled
|
||||||
|
{"CNC Operator", "", true}, // gate disabled (other side empty)
|
||||||
|
{"", "", true}, // both empty = both disabled
|
||||||
|
{"Boxes", "Box", true}, // -es plural
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := roleEqual(c.a, c.b)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("roleEqual(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRoleNormalize_NonPluralS locks the convergent finding from the
|
||||||
|
// role_gate_v1 scrum (Opus + Kimi + Qwen): words ending in 's' that
|
||||||
|
// aren't plurals must NOT be stripped. Bug shape per Opus' write-up:
|
||||||
|
// the original implementation collapsed "Sales" to "Sale", "Logistics"
|
||||||
|
// to "Logistic", etc., which would silently fail role-equality on
|
||||||
|
// every staffing client whose roles include those tokens.
|
||||||
|
func TestRoleNormalize_NonPluralS(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in, want string
|
||||||
|
}{
|
||||||
|
// Allowlist: end in 's' but represent role names as-is.
|
||||||
|
{"Sales", "sales"},
|
||||||
|
{"Sales Associate", "sales associate"},
|
||||||
|
{"Sales Associates", "sales associate"}, // plural-stripped on the head noun only
|
||||||
|
{"Logistics", "logistics"},
|
||||||
|
{"Logistics Coordinator", "logistics coordinator"},
|
||||||
|
{"Operations Manager", "operations manager"},
|
||||||
|
{"Facilities", "facilities"},
|
||||||
|
// -ss (not plural): must not strip.
|
||||||
|
{"Press", "press"},
|
||||||
|
{"Press Operator", "press operator"},
|
||||||
|
{"Boss", "boss"},
|
||||||
|
// Real plurals still strip correctly.
|
||||||
|
{"Operators", "operator"},
|
||||||
|
{"Pickers", "picker"},
|
||||||
|
{"Boxes", "box"},
|
||||||
|
{"Dishes", "dish"},
|
||||||
|
// Multi-word real plurals: strip head only.
|
||||||
|
{"Forklift Operators", "forklift operator"},
|
||||||
|
{"Production Workers", "production worker"},
|
||||||
|
// Whitespace + case tolerance.
|
||||||
|
{" CNC Operator ", "cnc operator"},
|
||||||
|
{"FORKLIFT OPERATOR", "forklift operator"},
|
||||||
|
// Empty.
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := roleNormalize(c.in)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("roleNormalize(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRoleEqual_NonPluralS is the gate-level pairing for
|
||||||
|
// TestRoleNormalize_NonPluralS — proves that two equal-shape role
|
||||||
|
// strings compare equal AND two different-role strings (one plural,
|
||||||
|
// one allowlisted) compare different.
|
||||||
|
func TestRoleEqual_NonPluralS(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Same role, allowlisted token: must stay equal.
|
||||||
|
{"Sales", "sales", true},
|
||||||
|
{"Logistics Coordinator", "logistics coordinator", true},
|
||||||
|
{"Press Operator", "PRESS OPERATOR", true},
|
||||||
|
// Allowlisted vs naïve-singular (the original bug): Sales
|
||||||
|
// would have falsely matched "Sale" if normalize stripped 's'.
|
||||||
|
// "Sale" isn't in the allowlist + isn't a plural-of-something,
|
||||||
|
// so they should NOT match.
|
||||||
|
{"Sales", "Sale", false},
|
||||||
|
// Plural ↔ singular cross-comparison still works for legit plurals.
|
||||||
|
{"Sales Associates", "Sales Associate", true},
|
||||||
|
{"Forklift Operators", "Forklift Operator", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := roleEqual(c.a, c.b)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("roleEqual(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(f float64) float64 {
|
||||||
|
if f < 0 {
|
||||||
|
return -f
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func idsOf(rs []Result) []string {
|
||||||
|
out := make([]string, len(rs))
|
||||||
|
for i, r := range rs {
|
||||||
|
out[i] = r.ID
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
// Heuristic relevance filter for matrix-retrieved chunks. Port of
|
||||||
|
// /home/profit/lakehouse/mcp-server/relevance.ts (Rust system).
|
||||||
|
//
|
||||||
|
// What it does: drops "adjacency pollution" — chunks that scored
|
||||||
|
// well on cosine but are actually about code the focus file IMPORTS,
|
||||||
|
// not the focus file itself. Without this, a reviewer LLM
|
||||||
|
// hallucinates imported-crate internals as belonging to the focus
|
||||||
|
// file ("I see main.rs does X" when X is in queryd::context that
|
||||||
|
// main.rs only calls through).
|
||||||
|
//
|
||||||
|
// IMPORTANT: this filter is CODE-aware. The signals are pub fn,
|
||||||
|
// struct, enum, use, import, file paths. It works for the eventual
|
||||||
|
// lakehouse_arch_v1 / lakehouse_symbols_v1 / scrum_findings_v1
|
||||||
|
// corpora ports. It will NOT meaningfully filter staffing data
|
||||||
|
// (candidates, workers, placements) — those need a different
|
||||||
|
// mechanism (structured constraints + status gates) that lives
|
||||||
|
// outside this package. See the candidates reality test 2026-04-29
|
||||||
|
// for the kind of staffing-side mismatch this filter doesn't fix.
|
||||||
|
//
|
||||||
|
// Scoring signals (all 0..1, additive then can sign-flip):
|
||||||
|
// path_match +1.0 chunk.source/doc_id encodes focus.path
|
||||||
|
// filename_match +0.6 chunk text mentions focus's filename
|
||||||
|
// defined_match +0.6 chunk text mentions focus.defined_symbols
|
||||||
|
// token_overlap +0.4 jaccard of non-stopword tokens
|
||||||
|
// prefix_match +0.3 chunk source shares first-2-segment prefix
|
||||||
|
// import_penalty -0.5 mentions ONLY imported symbols, no defined ones
|
||||||
|
//
|
||||||
|
// Threshold default 0.3 — same value the Rust observer ships.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultRelevanceThreshold is the value the Rust observer ships.
|
||||||
|
// Empirically tuned to keep direct hits and drop adjacency pollution.
|
||||||
|
const DefaultRelevanceThreshold = 0.3
|
||||||
|
|
||||||
|
// stopwords is the same list as relevance.ts. Includes English
|
||||||
|
// articles + common Rust/TS keywords that would otherwise flood
|
||||||
|
// jaccard scores between any two source files.
|
||||||
|
var stopwords = func() map[string]struct{} {
|
||||||
|
list := []string{
|
||||||
|
"the", "a", "an", "and", "or", "but", "if", "then", "else", "is", "are", "was", "were",
|
||||||
|
"be", "been", "being", "of", "in", "on", "at", "to", "for", "with", "by", "from", "as",
|
||||||
|
"that", "this", "these", "those", "it", "its", "they", "them", "their", "we", "our",
|
||||||
|
"you", "your", "i", "me", "my", "not", "no", "so", "do", "does", "did", "done",
|
||||||
|
"will", "would", "could", "should", "can", "may", "might", "must", "shall",
|
||||||
|
"fn", "let", "mut", "pub", "use", "mod", "struct", "enum", "trait", "impl", "self",
|
||||||
|
"type", "const", "static", "async", "await", "return", "match", "ok", "err", "some",
|
||||||
|
"none", "into", "from", "ref", "box", "arc", "rc", "vec", "string", "str",
|
||||||
|
}
|
||||||
|
m := make(map[string]struct{}, len(list))
|
||||||
|
for _, s := range list {
|
||||||
|
m[s] = struct{}{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// FocusFile is what we're filtering chunks against. Path is required
|
||||||
|
// for path_match; Content lets the filter auto-extract Defined and
|
||||||
|
// ImportedSymbols when callers haven't already done so.
|
||||||
|
type FocusFile struct {
|
||||||
|
Path string
|
||||||
|
Content string
|
||||||
|
DefinedSymbols []string
|
||||||
|
ImportedSymbols []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CandidateChunk is a single retrieved item to score. Source is the
|
||||||
|
// corpus name; DocID is the chunk identifier; Score is the upstream
|
||||||
|
// cosine signal (carried through but not used by this filter — the
|
||||||
|
// matrix layer uses cosine for ranking, this filter for retention).
|
||||||
|
type CandidateChunk struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
DocID string `json:"doc_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScoredChunk wraps a chunk with its computed relevance + the list
|
||||||
|
// of signals that fired. Reasons makes the filter auditable —
|
||||||
|
// debugging "why did this chunk get kept/dropped" is the hard part.
|
||||||
|
type ScoredChunk struct {
|
||||||
|
CandidateChunk
|
||||||
|
Relevance float64 `json:"relevance"`
|
||||||
|
Reasons []string `json:"reasons"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterResult is the output of FilterChunks. Kept + Dropped are
|
||||||
|
// disjoint and together cover the input. TotalIn is for sanity
|
||||||
|
// checks; FocusPath echoes input for logging.
|
||||||
|
type FilterResult struct {
|
||||||
|
Kept []ScoredChunk `json:"kept"`
|
||||||
|
Dropped []ScoredChunk `json:"dropped"`
|
||||||
|
Threshold float64 `json:"threshold"`
|
||||||
|
FocusPath string `json:"focus_path"`
|
||||||
|
TotalIn int `json:"total_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokenize lowercases, splits on identifier boundaries (>=3 chars),
|
||||||
|
// and drops stopwords. Used by Jaccard for token_overlap. Mirrors
|
||||||
|
// the TS regex /[a-z_][a-z0-9_]{2,}/g — RE2-compatible as written.
|
||||||
|
var tokenRe = regexp.MustCompile(`[a-z_][a-z0-9_]{2,}`)
|
||||||
|
|
||||||
|
func Tokenize(text string) map[string]struct{} {
|
||||||
|
out := make(map[string]struct{})
|
||||||
|
if text == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, m := range tokenRe.FindAllString(strings.ToLower(text), -1) {
|
||||||
|
if _, skip := stopwords[m]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[m] = struct{}{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jaccard returns |A ∩ B| / |A ∪ B|. 0 when either set is empty
|
||||||
|
// (matches the TS contract).
|
||||||
|
func Jaccard(a, b map[string]struct{}) float64 {
|
||||||
|
if len(a) == 0 || len(b) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var inter int
|
||||||
|
for k := range a {
|
||||||
|
if _, ok := b[k]; ok {
|
||||||
|
inter++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
union := len(a) + len(b) - inter
|
||||||
|
if union == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(inter) / float64(union)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractDefinedSymbols pulls pub-symbol names from Rust/TS source.
|
||||||
|
// Conservative — would rather miss a symbol than over-match. Patterns
|
||||||
|
// match exactly the TS impl; \b and (?:...) are RE2-supported. Case-
|
||||||
|
// sensitivity matches TS: pub fn is lowercase, struct/enum/trait/etc
|
||||||
|
// are PascalCase, const is SCREAMING_CASE. Only the "pub fn" match
|
||||||
|
// uses (?i) because TS uses /gi explicitly there (the rest are /g).
|
||||||
|
var definedPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)\bpub\s+(?:async\s+)?fn\s+([a-z_][a-z0-9_]*)`),
|
||||||
|
regexp.MustCompile(`\bpub\s+struct\s+([A-Z][A-Za-z0-9_]*)`),
|
||||||
|
regexp.MustCompile(`\bpub\s+enum\s+([A-Z][A-Za-z0-9_]*)`),
|
||||||
|
regexp.MustCompile(`\bpub\s+trait\s+([A-Z][A-Za-z0-9_]*)`),
|
||||||
|
regexp.MustCompile(`\bpub\s+const\s+([A-Z_][A-Z0-9_]*)`),
|
||||||
|
regexp.MustCompile(`\bpub\s+type\s+([A-Z][A-Za-z0-9_]*)`),
|
||||||
|
regexp.MustCompile(`\bexport\s+(?:async\s+)?function\s+([a-z_][a-zA-Z0-9_]*)`),
|
||||||
|
regexp.MustCompile(`\bexport\s+class\s+([A-Z][A-Za-z0-9_]*)`),
|
||||||
|
regexp.MustCompile(`\bexport\s+interface\s+([A-Z][A-Za-z0-9_]*)`),
|
||||||
|
regexp.MustCompile(`\bexport\s+(?:const|let|var)\s+([a-zA-Z_][a-zA-Z0-9_]*)`),
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractDefinedSymbols(content string) []string {
|
||||||
|
if content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var out []string
|
||||||
|
for _, re := range definedPatterns {
|
||||||
|
for _, m := range re.FindAllStringSubmatch(content, -1) {
|
||||||
|
if len(m) < 2 || m[1] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[m[1]]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[m[1]] = struct{}{}
|
||||||
|
out = append(out, m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rustUseRe matches `use foo::bar::Baz;`, `use foo::{Bar, Baz};`,
|
||||||
|
// `use foo::bar as alias;`. Lazy `*?` so we don't run into the next
|
||||||
|
// `;` boundary too eagerly.
|
||||||
|
var rustUseRe = regexp.MustCompile(`\buse\s+([A-Za-z_][A-Za-z0-9_:{}, \n]*?);`)
|
||||||
|
|
||||||
|
// tsImportRe matches `import { X, Y } from "foo"` and `import X from "foo"`.
|
||||||
|
var tsImportRe = regexp.MustCompile(`\bimport\s+(?:\{([^}]+)\}|([A-Za-z_][A-Za-z0-9_]*))\s+from`)
|
||||||
|
|
||||||
|
// identRe extracts identifiers from a use/import block.
|
||||||
|
var identRe = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_]*`)
|
||||||
|
|
||||||
|
func ExtractImportedSymbols(content string) []string {
|
||||||
|
if content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ignore := map[string]bool{
|
||||||
|
"use": true, "as": true, "crate": true, "super": true, "self": true, "mod": true,
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var out []string
|
||||||
|
add := func(tok string) {
|
||||||
|
if len(tok) <= 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ignore[tok] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[tok]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[tok] = struct{}{}
|
||||||
|
out = append(out, tok)
|
||||||
|
}
|
||||||
|
for _, m := range rustUseRe.FindAllStringSubmatch(content, -1) {
|
||||||
|
if len(m) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ident := range identRe.FindAllString(m[1], -1) {
|
||||||
|
add(ident)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range tsImportRe.FindAllStringSubmatch(content, -1) {
|
||||||
|
if len(m) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
block := m[1]
|
||||||
|
if block == "" {
|
||||||
|
block = m[2]
|
||||||
|
}
|
||||||
|
for _, ident := range identRe.FindAllString(block, -1) {
|
||||||
|
add(ident)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilePrefix returns the first two path segments joined by "/" —
|
||||||
|
// e.g. "crates/queryd/src/foo.rs" → "crates/queryd". Used for cheap
|
||||||
|
// "same crate" comparisons; mirrors pathway_memory's notion.
|
||||||
|
func FilePrefix(path string) string {
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
if len(parts) > 2 {
|
||||||
|
parts = parts[:2]
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScoreRelevance computes the additive 0..1-ish score plus the list
|
||||||
|
// of signals that fired. Negative scores are possible (import_penalty
|
||||||
|
// without compensating positive signal). Pure function — no side
|
||||||
|
// effects, no I/O.
|
||||||
|
func ScoreRelevance(focus FocusFile, chunk CandidateChunk) (float64, []string) {
|
||||||
|
var score float64
|
||||||
|
var reasons []string
|
||||||
|
|
||||||
|
focusPath := focus.Path
|
||||||
|
focusBase := ""
|
||||||
|
if focusPath != "" {
|
||||||
|
parts := strings.Split(focusPath, "/")
|
||||||
|
focusBase = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
chunkText := chunk.Text
|
||||||
|
chunkSource := chunk.Source
|
||||||
|
chunkDocID := chunk.DocID
|
||||||
|
|
||||||
|
// path_match: chunk's provenance encodes the focus path or filename.
|
||||||
|
if focusPath != "" && (strings.Contains(chunkSource, focusPath) ||
|
||||||
|
strings.Contains(chunkDocID, focusPath) ||
|
||||||
|
strings.Contains(chunkText, focusPath)) {
|
||||||
|
score += 1.0
|
||||||
|
reasons = append(reasons, "path_match")
|
||||||
|
} else if focusBase != "" && len(focusBase) > 4 &&
|
||||||
|
(strings.Contains(chunkText, focusBase) || strings.Contains(chunkDocID, focusBase)) {
|
||||||
|
score += 0.6
|
||||||
|
reasons = append(reasons, "filename_match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// defined_match: chunk text mentions symbols this file actually defines.
|
||||||
|
defined := focus.DefinedSymbols
|
||||||
|
if len(defined) == 0 && focus.Content != "" {
|
||||||
|
defined = ExtractDefinedSymbols(focus.Content)
|
||||||
|
}
|
||||||
|
if len(defined) > 0 {
|
||||||
|
var hits int
|
||||||
|
for _, s := range defined {
|
||||||
|
if len(s) > 2 && strings.Contains(chunkText, s) {
|
||||||
|
hits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hits > 0 {
|
||||||
|
denom := len(defined)
|
||||||
|
if denom < 1 {
|
||||||
|
denom = 1
|
||||||
|
}
|
||||||
|
ratio := float64(hits) / float64(denom)
|
||||||
|
if ratio > 1 {
|
||||||
|
ratio = 1
|
||||||
|
}
|
||||||
|
score += 0.6 * ratio
|
||||||
|
reasons = append(reasons, fmt.Sprintf("defined_match(%d/%d)", hits, len(defined)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// token_overlap: jaccard of non-stopword tokens.
|
||||||
|
if focus.Content != "" {
|
||||||
|
overlap := Jaccard(Tokenize(focus.Content), Tokenize(chunkText))
|
||||||
|
if overlap > 0.05 {
|
||||||
|
score += 0.4 * overlap
|
||||||
|
reasons = append(reasons, fmt.Sprintf("token_overlap(%.2f)", overlap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefix_match: same first-2-segments (e.g. crates/queryd).
|
||||||
|
if focusPath != "" {
|
||||||
|
fp := FilePrefix(focusPath)
|
||||||
|
if fp != "" && (strings.Contains(chunkSource, fp) ||
|
||||||
|
strings.Contains(chunkDocID, fp) ||
|
||||||
|
strings.Contains(chunkText, fp)) {
|
||||||
|
score += 0.3
|
||||||
|
reasons = append(reasons, "prefix_match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// import_penalty: chunk mentions only imported symbols, no defined
|
||||||
|
// ones. Strong signal of adjacency pollution — the chunk is about
|
||||||
|
// what we IMPORT, not what we ARE.
|
||||||
|
imported := focus.ImportedSymbols
|
||||||
|
if len(imported) == 0 && focus.Content != "" {
|
||||||
|
imported = ExtractImportedSymbols(focus.Content)
|
||||||
|
}
|
||||||
|
if len(imported) > 0 && len(defined) > 0 {
|
||||||
|
var importHits, definedHits int
|
||||||
|
for _, s := range imported {
|
||||||
|
if len(s) > 2 && strings.Contains(chunkText, s) {
|
||||||
|
importHits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range defined {
|
||||||
|
if len(s) > 2 && strings.Contains(chunkText, s) {
|
||||||
|
definedHits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if importHits > 0 && definedHits == 0 {
|
||||||
|
score -= 0.5
|
||||||
|
reasons = append(reasons, fmt.Sprintf("import_only(%d)", importHits))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return score, reasons
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterChunks scores every chunk and partitions by threshold. The
|
||||||
|
// caller picks the threshold; pass 0 to keep everything (caller-as-
|
||||||
|
// intent contract — no auto-default substitution, since a literal 0
|
||||||
|
// is meaningful as "keep everything I scored").
|
||||||
|
func FilterChunks(focus FocusFile, chunks []CandidateChunk, threshold float64) FilterResult {
|
||||||
|
kept := make([]ScoredChunk, 0, len(chunks))
|
||||||
|
dropped := make([]ScoredChunk, 0)
|
||||||
|
for _, c := range chunks {
|
||||||
|
score, reasons := ScoreRelevance(focus, c)
|
||||||
|
sc := ScoredChunk{CandidateChunk: c, Relevance: score, Reasons: reasons}
|
||||||
|
if score >= threshold {
|
||||||
|
kept = append(kept, sc)
|
||||||
|
} else {
|
||||||
|
dropped = append(dropped, sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FilterResult{
|
||||||
|
Kept: kept,
|
||||||
|
Dropped: dropped,
|
||||||
|
Threshold: threshold,
|
||||||
|
FocusPath: focus.Path,
|
||||||
|
TotalIn: len(chunks),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenize(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
text string
|
||||||
|
want []string // expected tokens (sorted check inside)
|
||||||
|
}{
|
||||||
|
{"", nil},
|
||||||
|
{"the quick brown fox", []string{"quick", "brown", "fox"}}, // stopwords dropped
|
||||||
|
{"hello WORLD", []string{"hello", "world"}}, // lowercase
|
||||||
|
{"a b c", nil}, // all under 3 chars
|
||||||
|
{"struct Foo", []string{"foo"}}, // "struct" is a stopword, identifiers OK
|
||||||
|
{"crates/queryd/db.go", []string{"crates", "queryd"}}, // db.go: "db" is 2 chars, "go" is 2 chars
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := Tokenize(c.text)
|
||||||
|
if len(got) != len(c.want) {
|
||||||
|
t.Errorf("Tokenize(%q): want %d tokens %v, got %d %v", c.text, len(c.want), c.want, len(got), got)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, w := range c.want {
|
||||||
|
if _, ok := got[w]; !ok {
|
||||||
|
t.Errorf("Tokenize(%q): missing token %q in %v", c.text, w, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJaccard(t *testing.T) {
|
||||||
|
mk := func(tokens ...string) map[string]struct{} {
|
||||||
|
m := make(map[string]struct{})
|
||||||
|
for _, t := range tokens {
|
||||||
|
m[t] = struct{}{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
a, b map[string]struct{}
|
||||||
|
want float64
|
||||||
|
epsilon float64
|
||||||
|
}{
|
||||||
|
{"both empty", mk(), mk(), 0, 0},
|
||||||
|
{"a empty", mk(), mk("x"), 0, 0},
|
||||||
|
{"identical", mk("x", "y"), mk("x", "y"), 1, 0},
|
||||||
|
{"disjoint", mk("a", "b"), mk("c", "d"), 0, 0},
|
||||||
|
{"half overlap", mk("a", "b"), mk("b", "c"), 1.0 / 3.0, 0.001},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := Jaccard(c.a, c.b)
|
||||||
|
if got < c.want-c.epsilon || got > c.want+c.epsilon {
|
||||||
|
t.Errorf("%s: want %.3f, got %.3f", c.name, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDefinedSymbols(t *testing.T) {
|
||||||
|
rust := `
|
||||||
|
pub fn search_chunks(query: &str) -> Vec<Chunk> { todo!() }
|
||||||
|
pub async fn build_index() {}
|
||||||
|
pub struct ChunkRegistry {}
|
||||||
|
pub enum Distance { Cosine, Euclidean }
|
||||||
|
pub trait Searcher {}
|
||||||
|
pub const MAX_K: usize = 1000;
|
||||||
|
pub type ChunkMap = HashMap<String, Chunk>;
|
||||||
|
|
||||||
|
fn private_helper() {} // not pub, must NOT match
|
||||||
|
struct PrivateOnly {} // not pub, must NOT match
|
||||||
|
`
|
||||||
|
got := ExtractDefinedSymbols(rust)
|
||||||
|
want := []string{"search_chunks", "build_index", "ChunkRegistry", "Distance", "Searcher", "MAX_K", "ChunkMap"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("Rust extract: want %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
for _, w := range want {
|
||||||
|
if !contains(got, w) {
|
||||||
|
t.Errorf("Rust: missing %q in %v", w, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Negative cases — these should NOT match.
|
||||||
|
for _, neg := range []string{"private_helper", "PrivateOnly"} {
|
||||||
|
if contains(got, neg) {
|
||||||
|
t.Errorf("Rust: should not match %q in %v", neg, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := `
|
||||||
|
export function tokenize(text: string) {}
|
||||||
|
export async function loadCorpus() {}
|
||||||
|
export class IndexRegistry {}
|
||||||
|
export interface FocusFile {}
|
||||||
|
export const STOPWORDS = new Set();
|
||||||
|
export let counter = 0;
|
||||||
|
|
||||||
|
function privateTs() {} // not export, must NOT match
|
||||||
|
class Internal {} // not export, must NOT match
|
||||||
|
`
|
||||||
|
got = ExtractDefinedSymbols(ts)
|
||||||
|
want = []string{"tokenize", "loadCorpus", "IndexRegistry", "FocusFile", "STOPWORDS", "counter"}
|
||||||
|
for _, w := range want {
|
||||||
|
if !contains(got, w) {
|
||||||
|
t.Errorf("TS: missing %q in %v", w, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, neg := range []string{"privateTs", "Internal"} {
|
||||||
|
if contains(got, neg) {
|
||||||
|
t.Errorf("TS: should not match %q in %v", neg, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractImportedSymbols(t *testing.T) {
|
||||||
|
rust := `
|
||||||
|
use catalogd::Registry;
|
||||||
|
use vectord::{Index, IndexParams};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
`
|
||||||
|
got := ExtractImportedSymbols(rust)
|
||||||
|
for _, w := range []string{"catalogd", "Registry", "vectord", "Index", "IndexParams", "collections", "HashMap"} {
|
||||||
|
if !contains(got, w) {
|
||||||
|
t.Errorf("Rust use: missing %q in %v", w, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, neg := range []string{"use", "as"} {
|
||||||
|
if contains(got, neg) {
|
||||||
|
t.Errorf("Rust use: should not match keyword %q in %v", neg, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := `
|
||||||
|
import { tokenize, jaccard } from "./relevance";
|
||||||
|
import express from "express";
|
||||||
|
`
|
||||||
|
got = ExtractImportedSymbols(ts)
|
||||||
|
for _, w := range []string{"tokenize", "jaccard", "express"} {
|
||||||
|
if !contains(got, w) {
|
||||||
|
t.Errorf("TS import: missing %q in %v", w, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilePrefix(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path, want string
|
||||||
|
}{
|
||||||
|
{"crates/queryd/src/foo.rs", "crates/queryd"},
|
||||||
|
{"top.rs", "top.rs"},
|
||||||
|
{"a/b/c/d", "a/b"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := FilePrefix(c.path)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("FilePrefix(%q): want %q, got %q", c.path, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreRelevance_PathMatch(t *testing.T) {
|
||||||
|
focus := FocusFile{Path: "crates/queryd/db.go"}
|
||||||
|
chunk := CandidateChunk{Source: "lakehouse_arch_v1", DocID: "phase:queryd", Text: "code at crates/queryd/db.go does X"}
|
||||||
|
score, reasons := ScoreRelevance(focus, chunk)
|
||||||
|
if score < 1.0 {
|
||||||
|
t.Errorf("path_match should give >=1.0; got %.2f reasons=%v", score, reasons)
|
||||||
|
}
|
||||||
|
if !contains(reasons, "path_match") {
|
||||||
|
t.Errorf("expected path_match in reasons: %v", reasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreRelevance_ImportPenalty(t *testing.T) {
|
||||||
|
// Focus defines Foo; chunk only mentions Bar (imported). Should
|
||||||
|
// fire import_only penalty.
|
||||||
|
focus := FocusFile{
|
||||||
|
Path: "crates/foo/main.go",
|
||||||
|
Content: "pub fn run() {}\npub struct Foo {}\nuse barlib::Bar;\n",
|
||||||
|
DefinedSymbols: []string{"Foo"},
|
||||||
|
ImportedSymbols: []string{"Bar"},
|
||||||
|
}
|
||||||
|
chunk := CandidateChunk{
|
||||||
|
Source: "barlib_corpus", DocID: "barlib:Bar:42",
|
||||||
|
Text: "Bar handles the actual lookup logic and returns a Result.",
|
||||||
|
}
|
||||||
|
score, reasons := ScoreRelevance(focus, chunk)
|
||||||
|
if !contains(reasons, "import_only(1)") {
|
||||||
|
t.Errorf("expected import_only penalty: reasons=%v score=%.2f", reasons, score)
|
||||||
|
}
|
||||||
|
if score >= 0 {
|
||||||
|
// Without other positive signals, score should be net-negative.
|
||||||
|
t.Errorf("expected negative net score; got %.2f reasons=%v", score, reasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterChunks_ThresholdSplitsKeptDropped(t *testing.T) {
|
||||||
|
focus := FocusFile{Path: "crates/queryd/db.go"}
|
||||||
|
chunks := []CandidateChunk{
|
||||||
|
{Source: "code", DocID: "queryd:db.go", Text: "crates/queryd/db.go is the focus"}, // path match → kept
|
||||||
|
{Source: "elsewhere", DocID: "phase:0", Text: "no match anywhere"}, // dropped
|
||||||
|
}
|
||||||
|
res := FilterChunks(focus, chunks, DefaultRelevanceThreshold)
|
||||||
|
if len(res.Kept) != 1 || len(res.Dropped) != 1 {
|
||||||
|
t.Errorf("split: kept=%d dropped=%d (want 1/1)", len(res.Kept), len(res.Dropped))
|
||||||
|
}
|
||||||
|
if res.TotalIn != 2 {
|
||||||
|
t.Errorf("TotalIn: want 2, got %d", res.TotalIn)
|
||||||
|
}
|
||||||
|
if res.FocusPath != focus.Path {
|
||||||
|
t.Errorf("FocusPath echo: want %q, got %q", focus.Path, res.FocusPath)
|
||||||
|
}
|
||||||
|
// Sanity: everything in Kept has Relevance >= threshold.
|
||||||
|
for _, c := range res.Kept {
|
||||||
|
if c.Relevance < DefaultRelevanceThreshold {
|
||||||
|
t.Errorf("kept chunk below threshold: %v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range res.Dropped {
|
||||||
|
if c.Relevance >= DefaultRelevanceThreshold {
|
||||||
|
t.Errorf("dropped chunk at/above threshold: %v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilterChunks_AdjacencyPollutionScenario is the headline test —
|
||||||
|
// the exact case the filter exists to catch. Focus file is
|
||||||
|
// crates/queryd/db.go which defines Connector and imports
|
||||||
|
// catalogd::Registry. A chunk about catalogd::Registry should be
|
||||||
|
// dropped (adjacency); a chunk about Connector should be kept.
|
||||||
|
func TestFilterChunks_AdjacencyPollutionScenario(t *testing.T) {
|
||||||
|
focus := FocusFile{
|
||||||
|
Path: "crates/queryd/src/db.go",
|
||||||
|
Content: `
|
||||||
|
package queryd
|
||||||
|
|
||||||
|
import "catalogd"
|
||||||
|
|
||||||
|
pub struct Connector {}
|
||||||
|
pub fn open_connector() *Connector { return nil }
|
||||||
|
use catalogd::Registry;
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
chunks := []CandidateChunk{
|
||||||
|
{
|
||||||
|
Source: "lakehouse_symbols_v1", DocID: "symbol:queryd::struct::Connector",
|
||||||
|
Text: "Connector wraps the DuckDB handle. open_connector creates one.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Source: "lakehouse_symbols_v1", DocID: "symbol:catalogd::struct::Registry",
|
||||||
|
Text: "Registry stores manifests. Used by ingestd and queryd.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res := FilterChunks(focus, chunks, DefaultRelevanceThreshold)
|
||||||
|
// Connector chunk should be kept (defined_match).
|
||||||
|
keptIDs := make([]string, len(res.Kept))
|
||||||
|
for i, c := range res.Kept {
|
||||||
|
keptIDs[i] = c.DocID
|
||||||
|
}
|
||||||
|
if !contains(keptIDs, "symbol:queryd::struct::Connector") {
|
||||||
|
t.Errorf("expected Connector chunk kept; got %v", keptIDs)
|
||||||
|
}
|
||||||
|
// The Registry chunk MIGHT pass threshold depending on token_overlap
|
||||||
|
// noise (queryd appears in its text too). The load-bearing assertion:
|
||||||
|
// Connector ranks ≥ Registry.
|
||||||
|
connectorRel, registryRel := -999.0, -999.0
|
||||||
|
for _, c := range append(res.Kept, res.Dropped...) {
|
||||||
|
if strings.Contains(c.DocID, "Connector") {
|
||||||
|
connectorRel = c.Relevance
|
||||||
|
}
|
||||||
|
if strings.Contains(c.DocID, "Registry") {
|
||||||
|
registryRel = c.Relevance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if connectorRel <= registryRel {
|
||||||
|
t.Errorf("Connector should outrank Registry: connector=%.2f registry=%.2f", connectorRel, registryRel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack []string, needle string) bool {
|
||||||
|
for _, h := range haystack {
|
||||||
|
if h == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user