Sunstead trust scoring project
0

Configure Feed

Select the types of activity you want to include in your feed.

Update sunstead: new modules (embed, voice, content, diffs, merged, vouchsafe), web UI, docs, scorer Dockerfile

+5170 -92
+4
.envrc.example
··· 16 16 export ANTHROPIC_API_KEY="sk-ant-..." 17 17 export CLAUDE_MODEL="claude-sonnet-4-6" 18 18 19 + # Featherless embeddings (diff/slop-similarity path). EMBED_DIMENSIONS optional (MRL). 20 + export FEATHERLESS_API_KEY="..." 21 + export EMBED_MODEL="Qwen/Qwen3-Embedding-4B" 22 + 19 23 # Create the venv ON the drive, not in the repo: 20 24 # python -m venv "$DATA_ROOT/venv" && source "$DATA_ROOT/venv/bin/activate"
+16 -7
README.md
··· 9 9 with isotonic calibration; GraphSAGE trained offline and compared (not served — it doesn't 10 10 beat M5 on this sparse graph, and the PRD says ship it only if it does); the 11 11 attestation-gated sensitive-repo tier (6.13); AT-Proto writeback of assessments as records 12 - (6.11); and the Tangled browser overlay (7.4). Only the ElevenLabs voice briefing is left 13 - as a seam — see "What's skipped". 12 + (6.11); the diff-embedding slop signal (6.12); a spoken `/brief` (ElevenLabs); and the 13 + Tangled browser overlay (7.4). 14 14 15 15 ## Layout 16 16 ··· 147 147 - **Browser overlay (7.4).** `extension/` is a minimal MV3 content script that injects a 148 148 trust hat onto tangled.org from the same `/score` API. Load unpacked; see `extension/README.md`. 149 149 Confirm the DID selector against the real DOM (the UI analog of confirming NSIDs). 150 + - **Diff-embedding slop signal (6.12).** `trust-embed --build` embeds **every** scraped PR diff 151 + (Featherless / `Qwen3-Embedding-4B`) into the `diff_vectors` table — idempotent and resumable 152 + (`pr_id NOT IN diff_vectors`), so re-run it as `trust.backfill` keeps filling `pull_requests`, 153 + or leave `trust-embed --build --watch` running to keep pace. Scoring then cosine-k-NNs each new 154 + diff against the embeddings of *currently* known-bad PRs (`slop_score` joins `pr_labels` 155 + `clean_merge=0`, so re-labelling never needs a re-embed) and hands the max similarity to Claude 156 + as a `machine_findings` hint (advisory — surfaces in the explanation, never flips the gate). 157 + Vector search stays inside DuckDB; no key → nothing embedded and the signal is just absent. 158 + - **Spoken briefing (M7).** `GET /brief/{did}` composes a speakable summary of the decision 159 + (no DIDs read aloud) and returns `audio/mpeg` when `ELEVENLABS_API_KEY` is set, JSON text 160 + otherwise. `trust.voice.brief_text` is the composer; reused by the API. 150 161 151 162 ## What's skipped (and when to add it) 152 163 153 - - **ElevenLabs voice briefing (M7).** A thin wrapper: the explanation `summary` is already 154 - "suitable to read aloud" (6.6), so a `/brief/{did}` endpoint that pipes it to TTS is the 155 - whole job — add when you have an ElevenLabs key. 156 164 - **Per-PR writeback subject.** `sh.tangled.trust.score` currently keys on the contributor 157 165 DID; carry `pr_id` on the `scores` table to reference a specific PR's `at://` URI. 158 166 - **SvelteKit frontend.** The three surfaces ship as built-in static pages (the PRD blesses 159 167 this for the dashboard); swap to SvelteKit if you need the richer UI kit / native overlay. 160 - - **External signals (6.12): OSV/secret-scan/SAST.** `review_pr` already accepts 161 - `machine_findings` as structured input — wire the scanners into that arg. 168 + - **More external signals (6.12): OSV/secret-scan/SAST.** `review_pr` already accepts 169 + `machine_findings` (the slop similarity is the first one wired in) — add the scanners' output 170 + to that same dict.
+228
docs/content-tower-plan.md
··· 1 + # Content Tower (Tier 1: frozen embeddings + calibrated head) — build plan 2 + 3 + Self-contained build doc. You can clear the conversation and hand this to a fresh 4 + agent. It captures the plan **and** the facts discovered while exploring the live data, 5 + so nothing here depends on chat history. 6 + 7 + --- 8 + 9 + ## 0. What this is 10 + 11 + The PRD fuses two independent signals through a **monotone gate** (not an average): 12 + 13 + - **Tower A — identity trust** (per-DID, sybil-resistant, **load-bearing**): EigenTrust 14 + over the vouch graph. Already built (`eigentrust.py`). 15 + - **Tower B — content risk** (per-PR, **identity-blind**): how risky *this diff* is, 16 + judged with no knowledge of the author. Today this is only Claude (`review.py`) at the 17 + gate, plus an advisory slop-kNN. **This doc builds the learned Tower B.** 18 + 19 + Tier 1 = run each diff through the **already-wired embedding transformer** 20 + (Featherless Qwen3-Embedding) and train a small **calibrated head** on `clean_merge`, 21 + using only the diff. Transformer representation power, no fine-tuning, leakage-free by 22 + construction (the model never sees identity). 23 + 24 + Tier 2 (fine-tuning a code transformer) is **deferred** until there are ~10³–10⁴ labeled 25 + diffs — below that it loses to frozen-embeddings + a linear head. Not in scope here. 26 + 27 + ### Non-negotiable constraints (PRD) — must hold for every phase 28 + - Content models judge **content, never identity**: no author handle/DID/history/aggregates 29 + feed Tower B. Diff + PR-intrinsic stats (size, files, discussion length) only. 30 + - Structural signal (Tower A / EigenTrust) stays load-bearing and sybil-resistant. 31 + - The gate is **not an average**: content can only **penalize**, never lift an untrusted DID 32 + into the fast lane. 33 + - Calibrated + explainable. Serve a learned model only if it **beats its baseline** on a 34 + proper holdout (same rule the GNN already follows). 35 + - Serviceless: single DuckDB file, large artifacts under `DATA_ROOT`. 36 + 37 + --- 38 + 39 + ## 1. Critical path 40 + 41 + ``` 42 + Phase 0: fetch diffs (patchBlobs) ─┐ 43 + ├─► Phase 2 embed ─► Phase 3 head ─► Phase 4 fuse ─► Phase 5 eval-gate 44 + Phase 1: merged labels ────────────┘ 45 + ``` 46 + 47 + **Phase 0 + Phase 1 are prerequisites for ANY content model** (transformer or not) and for 48 + Claude review and slop-kNN — all three are dead without diffs. Start at Phase 0. 49 + 50 + --- 51 + 52 + ## 2. Live data facts (as observed) 53 + 54 + - **Live DB**: `/Volumes/spectrofi-rec/tangled-data/duckdb/trust.duckdb` 55 + (`DATA_ROOT=/Volumes/spectrofi-rec/tangled-data`). The repo-local `.data/…` is a stale dev DB — ignore it. 56 + - Backfill is rich but the derived/label layer is **stale** (it ran before some `derive()` 57 + branches existed). Snapshot: 58 + - events 83,991 · contributors 10,848 · **vouches 2,029 (+) / 37 (−)** · pulls 5,768 59 + - `seeds` = **0**, `pull_status` = **0** (collection was not in the old backfill — not even archived), 60 + `stars` = 0 (14,409 `feed.star` events archived but not re-derived), `diff_text` = **0** 61 + (patchBlobs never fetched), **0 positive `clean_merge` labels**, no trained model. 62 + - **Read the live DB read-only with retry** (single-writer; a held lock blocks every open): 63 + ```python 64 + import duckdb, time 65 + con=None 66 + for _ in range(80): 67 + try: con=duckdb.connect("/Volumes/spectrofi-rec/tangled-data/duckdb/trust.duckdb", read_only=True); break 68 + except duckdb.IOException: time.sleep(0.3) 69 + ``` 70 + Pause `ingest`/`api`/`backfill` before writing, or writes crawl on the lock. 71 + 72 + ### Record shapes you'll need (confirmed from the network) 73 + 74 + `sh.tangled.repo.pull` (the diff is a gzipped blob, NOT inline): 75 + ```json 76 + { "rounds": [ { "createdAt": "...", 77 + "patchBlob": { "$type": "blob", "ref": { "$link": "<CID>" }, 78 + "mimeType": "application/gzip", "size": 49502 } } ], 79 + "source": { "branch": "..." }, 80 + "target": { "branch": "...", "repo": "did:plc:…", "repoDid": "did:plc:…" } } 81 + ``` 82 + - `pr_id` convention (set in `ingest.derive`): `f"{author_did}/{collection}/{rkey}"`, 83 + e.g. `did:plc:X/sh.tangled.repo.pull/3mp…`. 84 + - The **latest round** (`rounds[-1]`) is the final proposed change — embed/review that. 85 + 86 + `sh.tangled.repo.pull.status` (authoritative outcome, public; sparse): 87 + ```json 88 + { "pull": "at://did:plc:X/sh.tangled.repo.pull/<rkey>", 89 + "status": "sh.tangled.repo.pull.status.merged" } // .merged / .closed / .open 90 + ``` 91 + - Status author may differ from the pull owner — parse `pr_id` from the `pull` field 92 + (`uri[len("at://"):]`), never from the status record's own did/rkey. (`derive()` already does this.) 93 + 94 + Knot git clone URL (for the Phase-1 label backstop, git-on-knots): `https://{knot}/{owner_did}/{repo}` 95 + (https, no auth for public repos; `git ls-remote` returns `refs/heads/main`). 96 + 97 + ### Existing code to build on 98 + - `src/trust/embed.py` — `index_diffs(con, limit=256)` already embeds every `pull_requests.diff_text` 99 + into `diff_vectors(pr_id, label, embedding DOUBLE[])`, idempotent/resumable; `embed()` returns 100 + `None` without `FEATHERLESS_API_KEY`; `slop_score()` cosine-kNN vs `clean_merge=0`. 101 + - `src/trust/backfill.py` — reuse `_pds(did)`, `_get(url)`, `_records(pds,did,coll)`, the 102 + `ThreadPoolExecutor` fan-out pattern, `_archive_and_derive`. 103 + - `src/trust/db.py` — `pull_requests.diff_text`, `pull_status`, `diff_vectors`, `pr_labels` 104 + view (`clean_merge`), `connection(read_only=…)`, `ensure_schema()`. 105 + - `src/trust/ingest.py` — `derive()` (pull / pull_status / star branches). 106 + - `src/trust/learned.py` — copy its shape: `FEATURE_COLS`, `_vec`, `train(split)`, 107 + `LearnedScorer`, isotonic calibration, `_reliability`, `MODEL_PATH = MODEL_DIR/…`. 108 + - `src/trust/fusion.py` — `score_pr`, `decide`, `should_review`, `_features_for`. 109 + - `src/trust/config.py` — `CFG.embed` (Featherless), `CFG.review`, `MODEL_DIR`. 110 + 111 + --- 112 + 113 + ## 3. Phases 114 + 115 + ### Phase 0 — Fetch the diffs (new `src/trust/diffs.py`) 116 + The highest-leverage unblock: lights up the content head, Claude review, **and** slop-kNN. 117 + 118 + Steps: 119 + 1. Select pulls needing a diff: `SELECT pr_id, author_did, record(from events) FROM pull_requests WHERE diff_text IS NULL`. 120 + The CID lives in the archived `events.record` JSON (`rounds[-1].patchBlob.ref.$link`); join 121 + `events` on `(did, collection, rkey)` or re-read it. 122 + 2. For each: resolve `_pds(author_did)`, then 123 + `GET {pds}/xrpc/com.atproto.sync.getBlob?did={author_did}&cid={cid}` → bytes. 124 + 3. `gzip.decompress(bytes).decode("utf-8", "replace")` → unified-diff text. Cap stored length 125 + (~50 KB; embeddings/Claude truncate anyway). `UPDATE pull_requests SET diff_text=? WHERE pr_id=?`. 126 + 4. Parallelize like `backfill`: network fetch in a 12-thread pool, DB writes in chunks (single writer). 127 + Skip missing/oversized blobs gracefully (never abort the run). 128 + 129 + Deliverable: `pull_requests.diff_text` populated for ~5,768 PRs (minutes of network). 130 + Self-check: a `demo()` that fetches one known blob and asserts it gunzips to text containing `diff`/`@@`. 131 + 132 + ### Phase 1 — Merged labels (you need a positive class) 133 + - Targeted scrape of `sh.tangled.repo.pull.status` (already mapped in `COLLECTION_KINDS` and handled 134 + in `derive()`): `python -m trust.backfill --collection sh.tangled.repo.pull.status` (capped first 135 + with `--max-repos`). 136 + - **Measure positives** before building the head: 137 + `SELECT clean_merge, count(*) FROM pr_labels GROUP BY 1`. 138 + - **Risk:** pull.status is sparse. If positives are only tens, the head is data-starved too. 139 + Backstop = **git-on-knots `merged` detection** (clone default branch via the knot URL above, 140 + check whether each pull's patch landed) for broad `merged` coverage + `reverted`/`re-patched`. 141 + Only build the backstop if pull.status coverage proves insufficient. 142 + 143 + Deliverable: `pr_labels.clean_merge` with a real positive class (need ≥ a few hundred ideally; 144 + the trainer requires ≥4 rows spanning both classes as a hard floor). 145 + 146 + ### Phase 2 — Embed the diffs (frozen transformer) 147 + - Set `FEATHERLESS_API_KEY`. Run `index_diffs` to caught-up (loop while it returns > 0): 148 + ```python 149 + from trust.db import connection, ensure_schema 150 + from trust import embed 151 + ensure_schema() 152 + with connection(read_only=False) as con: 153 + while embed.index_diffs(con, limit=256): pass 154 + ``` 155 + - Optional GPU: self-host Qwen3-Embedding-4B (fits one GPU) to embed ~6k diffs locally for free 156 + instead of the API. The head itself is CPU-trivial. 157 + 158 + Deliverable: `diff_vectors` filled for every PR with a diff. 159 + 160 + ### Phase 3 — The calibrated head (new `src/trust/content.py`, Tower B) 161 + - `_xy(con)`: `X` = `diff_vectors.embedding` for PRs that have a non-NULL `clean_merge` 162 + (join `pr_labels`); `y` = `clean_merge`. Optionally concat **PR-intrinsic** scalars 163 + (`additions, deletions, files_touched, discussion_len`) and the slop-kNN similarity. 164 + **Never** identity/author features. 165 + - **Model: L2-normalize the embedding → logistic regression (linear probe, L2-reg) → isotonic 166 + or Platt calibration.** Linear probe is correct for frozen embeddings at low data; LightGBM on 167 + raw 2560-dim embeddings overfits — keep it only as an alt. 168 + - Time-split train/val (order by `opened_at`). Save `content.pkl` under `MODEL_DIR`. 169 + - `ContentScorer.prob(pr_id) -> P(content safe)`; expose `content_risk = 1 - P`. 170 + - Self-check `demo()`: on held-out PRs, a known-bad diff scores higher risk than a clean one; 171 + print the reliability curve. 172 + 173 + Deliverable: a calibrated content risk for **every** PR (cheap, no API), not just reviewed ones. 174 + 175 + ### Phase 4 — Fuse into the gate (monotone, unchanged) 176 + - In `fusion.score_pr`: the head supplies `content_risk` for all PRs; Claude (`review_pr`, gated by 177 + `should_review`) refines ambiguous/sensitive ones. Combine conservatively: 178 + `content_risk = max(model_risk, claude_risk)` so content still only **penalizes**. 179 + - Win: every PR gets a content signal; today only the Claude-reviewed subset does. 180 + - Keep `decide()` and its thresholds; surface the head's risk in the explanation 181 + (`build_reason`) like the other factors. 182 + 183 + ### Phase 5 — Eval + beat-the-baseline gate 184 + - Calibration: reliability curve (reuse `learned._reliability`). Ranking: AUC / average precision. 185 + - **Serve only if it beats**: (a) majority-class, (b) Claude-alone risk where available, 186 + (c) slop-kNN alone — on a **time-split AND a repo-holdout** (generalize to unseen repos). 187 + - Write a verdict (like `gnn` does); `fusion` consults it before using the head. 188 + 189 + --- 190 + 191 + ## 4. Effort & runtime 192 + 193 + | Phase | Build | Runtime | 194 + |---|---|---| 195 + | 0 diffs (`diffs.py`) | ~1 hr | few min (network) | 196 + | 1 labels (scrape) | wired | ~10 min capped | 197 + | 2 embed (`index_diffs`) | done | few min (API) | 198 + | 3 head (`content.py`) | ~1 hr | seconds | 199 + | 4 fuse (`fusion.py`) | ~30 min | — | 200 + | 5 eval-gate | ~30 min | seconds | 201 + 202 + ≈ half a day of build + minutes of runtime, given `FEATHERLESS_API_KEY` and enough Phase-1 positives. 203 + 204 + ## 5. GPU guidance 205 + - **Tier 1 needs no GPU** — embedding runs on Featherless (remote); the head is CPU-trivial. 206 + - Use a GPU now only to **self-host Qwen3-Embedding-4B** for free bulk embedding of ~6k diffs 207 + (skip API cost/limits). 208 + - Save the GPU for **Tier 2** (fine-tuning CodeBERT/StarEncoder) — deferred until ~10³–10⁴ 209 + labeled diffs exist. 210 + 211 + ## 6. Definition of done 212 + - `diffs.py` populates `diff_text`; `pr_labels` has a positive class; `diff_vectors` filled. 213 + - `content.py` trains a calibrated head, identity-blind, with a reliability curve. 214 + - It **beats** majority / Claude-alone / slop-kNN on a time + repo holdout, else it doesn't serve. 215 + - `fusion` consumes it monotonically (content only penalizes); explanation shows the content factor. 216 + - Smoke test added (mirror `tests/test_smoke.py` style: `importorskip` the embedding path; assert a 217 + bad diff out-risks a clean one). 218 + 219 + ## 7. Parallel unblock (not this tower, but the other gating item) 220 + Structural scoring is still blocked by **`seeds = 0`** + stale derives. Independent of Tower B: 221 + 1. `--rederive` from archived `events` (no network) → repopulates `stars` (and any archived 222 + collections) through the current `derive()`. 223 + 2. Seed real maintainer DIDs — top vouch-receivers are the anchors: 224 + `did:plc:onu3oqfahfubgbetlr4giknc` (141 in), `did:plc:wshs7t2adsemcrrd4snkeqli` (89), 225 + `did:plc:qfpnj4og54vl56wngdriaxug` (56)… → `INSERT INTO seeds …`. 226 + 3. `trust-train` once labels (Phase 1) exist. 227 + EigenTrust (Tower A) and the content head (Tower B) can be built in either order; the gate needs both. 228 + ```
+9 -1
mprocs.yaml
··· 15 15 autostart: true 16 16 17 17 api: 18 - shell: uv run trust-api # http://127.0.0.1:8000 (triage/dashboard/leaderboard) 18 + shell: uv run trust-api # http://127.0.0.1:8003 (JSON: triage/dashboard/leaderboard/graph) 19 19 autostart: true 20 + 21 + web: 22 + shell: cd web && bun run dev # http://127.0.0.1:5173 (SvelteKit UI -> proxies /api to :8003) 23 + autostart: true # set API_BASE to override the :8003 proxy default 24 + 25 + embed: 26 + shell: uv run trust-embed --build # (re)build the known-bad slop corpus (6.12) 27 + autostart: false # one-shot; needs FEATHERLESS_API_KEY, else indexes 0 20 28 21 29 ingest: 22 30 shell: uv run python -m trust.ingest # live firehose -> DuckDB
+4
pyproject.toml
··· 11 11 "uvicorn>=0.30", 12 12 "websockets>=12", 13 13 "anthropic>=0.40", 14 + "httpx>=0.27", 14 15 "pydantic>=2.7", 15 16 ] 16 17 ··· 27 28 trust-api = "trust.api:main" 28 29 trust-seed = "trust.seed:main" 29 30 trust-train = "trust.learned:main" 31 + trust-embed = "trust.embed:main" 32 + trust-diffs = "trust.diffs:main" 33 + trust-content = "trust.content:main" 30 34 trust-gnn = "trust.gnn:main" 31 35 trust-publish = "trust.atproto:main" 32 36
+14
scorer.Dockerfile
··· 1 + # CyberCred scorer: thin read-API over the trust DuckDB (no torch/lightgbm needed — 2 + # fusion._gnn_winner()/_scorer() degrade to EigenTrust + the precomputed scores table). 3 + FROM python:3.12-slim 4 + WORKDIR /app 5 + RUN pip install --no-cache-dir \ 6 + "duckdb>=1.1" "numpy>=1.26" "scipy>=1.11" "fastapi>=0.115" \ 7 + "uvicorn>=0.30" "httpx>=0.27" "pydantic>=2.7" "anthropic>=0.40" "websockets>=12" 8 + COPY src/ /app/src/ 9 + ENV PYTHONPATH=/app/src 10 + ENV DATA_ROOT=/data 11 + ENV DUCKDB_PATH=/data/trust.duckdb 12 + EXPOSE 8000 13 + # single worker: DuckDB is single-writer and ensure_schema() opens RW once at startup 14 + CMD ["uvicorn", "trust.api:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
+220 -29
src/trust/api.py
··· 10 10 from __future__ import annotations 11 11 12 12 import json 13 + import urllib.request 13 14 from contextlib import asynccontextmanager 15 + from functools import lru_cache 14 16 from pathlib import Path 15 17 16 18 from fastapi import Depends, FastAPI, HTTPException 17 19 from fastapi.middleware.cors import CORSMiddleware 18 - from fastapi.responses import FileResponse 20 + from fastapi.responses import FileResponse, Response 19 21 from fastapi.staticfiles import StaticFiles 20 22 from pydantic import BaseModel 21 23 22 24 from .config import CFG 23 25 from .db import connection, ensure_schema 24 - from . import eigentrust, fusion, review as review_mod 26 + from . import eigentrust, fusion, review as review_mod, voice 25 27 26 28 27 29 @asynccontextmanager ··· 48 50 return eigentrust.compute(con) 49 51 50 52 51 - @app.get("/score/{did}") 52 - def score(did: str, con=Depends(get_con)): 53 + def _gate_probs(con, er): 54 + """did -> the SAME calibrated P(clean) the gate uses (winning GNN -> M5 -> raw 55 + EigenTrust), so the dashboard mirrors real decisions instead of raw structural trust. 56 + M5 blends the non-vouch signals (age/merged-history/stars) that lift active-but-unvouched 57 + contributors; reading raw er.trust here hides that. Loads the scorer + features ONCE 58 + (per-did SQL x10k would crawl).""" 59 + gnn = fusion._gnn_winner() 60 + scorer = None if gnn else fusion._scorer() 61 + if gnn is None and scorer is None: 62 + return dict(er.trust) 63 + cols = [c[0] for c in con.execute("DESCRIBE features").fetchall()] 64 + di = cols.index("did") 65 + feats = {r[di]: dict(zip(cols, r)) for r in con.execute("SELECT * FROM features").fetchall()} 66 + return {did: (gnn.prob(did) if gnn else scorer.prob(did, feats.get(did) or {}, er)) 67 + for did in er.trust} 68 + 69 + 70 + def _profiles(con) -> dict: 71 + """did -> declared profile bits from the latest sh.tangled.actor.profile per did: 72 + display name (preferredHandle), avatar blob CID, declared links, location/pronouns/ 73 + description. One query, in-memory join onto the list endpoints (per-row SQL would 74 + crawl). Avatars resolve client-side via the bsky CDN thumbnail from (did + cid); 75 + PRD 6.10 wants avatar+handle on each row.""" 76 + clean = lambda s: (s or "").strip() or None 77 + out = {} 78 + for did, rec in con.execute( 79 + "SELECT did, record FROM events WHERE collection='sh.tangled.actor.profile' " 80 + "QUALIFY row_number() OVER (PARTITION BY did ORDER BY time_us DESC) = 1" 81 + ).fetchall(): 82 + try: 83 + d = json.loads(rec) 84 + except (TypeError, ValueError): 85 + continue 86 + av = d.get("avatar") 87 + out[did] = { 88 + "name": clean(d.get("preferredHandle")), 89 + "avatar_cid": av.get("ref", {}).get("$link") if isinstance(av, dict) else None, 90 + "links": [l for l in (d.get("links") or []) if isinstance(l, str) and l.strip()], 91 + "location": clean(d.get("location")), 92 + "pronouns": clean(d.get("pronouns")), 93 + "description": clean(d.get("description")), 94 + } 95 + return out 96 + 97 + 98 + def _assess(con, did: str) -> dict: 99 + """Shared assessment: latest written score, else a fresh structural compute.""" 53 100 er = _eigen(con) 54 101 feats = fusion._features_for(con, did) 55 102 structural, model_factors = fusion.structural_for(did, er, feats) # M5 calibrated, or raw EigenTrust ··· 62 109 reason = json.loads(latest[3]) if latest else fusion.build_reason( 63 110 did, structural, None, er, feats, model_factors) 64 111 prob = latest[1] if latest else structural 65 - return {"did": did, "structural_trust": structural, "content_risk": content_risk, 112 + handle = con.execute("SELECT handle FROM contributors WHERE did=?", [did]).fetchone() 113 + return {"did": did, "handle": handle[0] if handle else None, 114 + "structural_trust": structural, "content_risk": content_risk, 66 115 "calibrated_prob": prob, "decision": decision, "explanation": reason, 67 116 "top_factors": reason.get("top_factors", [])} 68 117 69 118 119 + @app.get("/score/{did}") 120 + def score(did: str, con=Depends(get_con)): 121 + return _assess(con, did) 122 + 123 + 124 + @lru_cache(maxsize=4096) # ponytail: in-process cache, cleared on restart. Batch-backfill if you want it persisted. 125 + def _resolve_identity(did: str) -> dict: 126 + """did -> {handle, pds} via the PLC directory (did:plc) or .well-known (did:web). 127 + The atproto handle IS the human identity/domain (e.g. alice.bsky.social).""" 128 + try: 129 + if did.startswith("did:plc:"): 130 + doc = json.loads(urllib.request.urlopen(f"https://plc.directory/{did}", timeout=5).read()) 131 + elif did.startswith("did:web:"): 132 + domain = did[len("did:web:"):].replace(":", "/") 133 + doc = json.loads(urllib.request.urlopen(f"https://{domain}/.well-known/did.json", timeout=5).read()) 134 + else: 135 + return {"handle": None, "pds": None} 136 + handle = next((a[len("at://"):] for a in (doc.get("alsoKnownAs") or []) if a.startswith("at://")), None) 137 + pds = next((s.get("serviceEndpoint") for s in (doc.get("service") or []) 138 + if s.get("type") == "AtprotoPersonalDataServer"), None) 139 + return {"handle": handle, "pds": pds} 140 + except Exception: 141 + return {"handle": None, "pds": None} # offline / unknown did -> caller falls back to the DID 142 + 143 + 144 + @app.get("/identity/{did}") 145 + def identity(did: str, con=Depends(get_con)): 146 + # Gate to known contributors: bounds resolution to our own DIDs (no SSRF via arbitrary did:web). 147 + if not con.execute("SELECT 1 FROM contributors WHERE did=?", [did]).fetchone(): 148 + raise HTTPException(404, "unknown did") 149 + stored = con.execute("SELECT handle FROM contributors WHERE did=?", [did]).fetchone() 150 + info = _resolve_identity(did) 151 + return {"did": did, "handle": (stored and stored[0]) or info["handle"], "pds": info["pds"]} 152 + 153 + 154 + @app.get("/brief/{did}") 155 + def brief(did: str, text: bool = False, con=Depends(get_con)): 156 + """Spoken briefing of an assessment (PRD M7, ElevenLabs). Returns audio/mpeg when 157 + ELEVENLABS_API_KEY is set, else the brief text as JSON (so it's demoable keyless).""" 158 + script = voice.brief_text(_assess(con, did)) 159 + audio = None if text else voice.synthesize(script) 160 + if audio is None: 161 + return {"did": did, "brief": script, "audio": False} 162 + return Response(content=audio, media_type="audio/mpeg") 163 + 164 + 70 165 class ReviewBody(BaseModel): 71 166 diff: str 72 167 title: str = "" ··· 86 181 def leaderboard(limit: int = 50, con=Depends(get_con)): 87 182 er = _eigen(con) 88 183 handles = dict(con.execute("SELECT did, handle FROM contributors").fetchall()) 184 + profiles = _profiles(con) 185 + vouches = dict(con.execute( # positive vouches received (the in-degree that earns trust) 186 + "SELECT subject_did, count(*) FROM vouches WHERE COALESCE(polarity,1) > 0 GROUP BY subject_did" 187 + ).fetchall()) 89 188 ranked = sorted(er.trust.items(), key=lambda kv: kv[1], reverse=True)[:limit] 90 189 return [{"did": d, "handle": handles.get(d), "calibrated_prob": round(t, 4), 91 - "decision": fusion.decide(t, None)} for d, t in ranked] 190 + "decision": fusion.decide(t, None), "profile": profiles.get(d), 191 + "vouches": vouches.get(d, 0)} for d, t in ranked] 192 + 193 + 194 + @app.get("/scores") 195 + def scores_all(con=Depends(get_con)): 196 + """Compact did -> {p, d, h} for every contributor (CyberCred badge-cache source). 197 + One EigenTrust compute; stored calibrated score wins, else structural + gate decision.""" 198 + er = _eigen(con) 199 + handles = dict(con.execute("SELECT did, handle FROM contributors").fetchall()) 200 + latest = {r[0]: (r[1], r[2]) for r in con.execute( 201 + "SELECT s.did, s.calibrated_prob, s.decision FROM scores s " 202 + "JOIN (SELECT did, max(as_of) m FROM scores GROUP BY did) l " 203 + "ON s.did=l.did AND s.as_of=l.m").fetchall()} 204 + out = {} 205 + for did in set(er.trust) | set(latest) | set(handles): 206 + if did in latest: 207 + p, d = latest[did] 208 + else: 209 + p = er.trust.get(did, 0.0) 210 + d = fusion.decide(p, None) 211 + out[did] = {"p": round(float(p), 4), "d": d, "h": handles.get(did)} 212 + return out 213 + 214 + 215 + @app.get("/graph") 216 + def graph(connected: bool = False, con=Depends(get_con)): 217 + """Vouch graph as {nodes, links} for the Obsidian-style force view (7.x). 218 + Every contributor is a node (trust drives size/color); vouches are the links. 219 + `?connected=1` drops unvouched contributors down to just the relationship core.""" 220 + er = _eigen(con) 221 + handles = dict(con.execute("SELECT did, handle FROM contributors").fetchall()) 222 + seeds = set(er.seeds) 223 + links = [{"source": v, "target": s, "polarity": p} 224 + for v, s, p in con.execute( 225 + "SELECT voucher_did, subject_did, polarity FROM vouches").fetchall() 226 + if v in er.trust and s in er.trust] # drop dangling edges the layout can't place 227 + dids = ({d for l in links for d in (l["source"], l["target"])} | seeds 228 + if connected else er.trust.keys()) # all known dids unless asked to trim 229 + nodes = [{"id": did, "handle": handles.get(did) or did[:14], 230 + "trust": round(er.trust[did], 4), "decision": fusion.decide(er.trust[did], None), 231 + "seed": did in seeds} 232 + for did in dids] 233 + return {"nodes": nodes, "links": links} 92 234 93 235 94 236 @app.get("/triage") 95 237 def triage(con=Depends(get_con)): 96 - """Open PRs grouped by decision, with the explanation breakdown (section 7.1).""" 238 + """Open PRs grouped by decision, with the explanation breakdown (section 7.1). 239 + One EigenTrust compute + one bulk scores query (was N per-PR queries + recomputes, 240 + which timed out at ~5.7k open PRs).""" 97 241 er = _eigen(con) 98 242 handles = dict(con.execute("SELECT did, handle FROM contributors").fetchall()) 243 + profiles = _profiles(con) 244 + # latest stored score per did in a single pass (prob, decision, reason already computed) 245 + scored = {r[0]: (r[1], r[2], r[3]) for r in con.execute( 246 + "SELECT s.did, s.calibrated_prob, s.decision, s.explanation_json FROM scores s " 247 + "JOIN (SELECT did, max(as_of) m FROM scores GROUP BY did) l " 248 + "ON s.did=l.did AND s.as_of=l.m").fetchall()} 99 249 rows = con.execute( 100 - "SELECT pr_id, author_did, repo FROM pull_requests WHERE NOT merged AND NOT closed_unmerged" 250 + # merged/closed_unmerged are NULL on backfilled PRs; `NOT NULL` is NULL (drops the 251 + # row), so COALESCE the unknowns to FALSE -- otherwise every open PR vanishes. 252 + "SELECT pr_id, author_did, repo FROM pull_requests " 253 + "WHERE merged IS NOT TRUE AND closed_unmerged IS NOT TRUE" 101 254 ).fetchall() 102 255 out = [] 103 256 for pr_id, did, repo in rows: 104 - structural = er.trust.get(did, 0.0) 105 - latest = con.execute( 106 - "SELECT calibrated_prob, decision, explanation_json FROM scores WHERE did=? " 107 - "ORDER BY as_of DESC LIMIT 1", [did] 108 - ).fetchone() 109 - prob = latest[0] if latest else structural 110 - decision = latest[1] if latest else fusion.decide(structural, None) 111 - reason = json.loads(latest[2]) if latest else fusion.build_reason( 112 - did, structural, None, er, fusion._features_for(con, did)) 257 + if did in scored: 258 + prob, decision, reason_json = scored[did] 259 + reason = json.loads(reason_json or "{}") 260 + else: # author never scored (rare): structural fallback, no per-PR recompute 261 + prob = er.trust.get(did, 0.0) 262 + decision = fusion.decide(prob, None) 263 + reason = {"top_factors": ["no stored score; raw structural trust"]} 113 264 out.append({"pr_id": pr_id, "repo": repo, "handle": handles.get(did), "did": did, 114 - "calibrated_prob": round(prob, 4), "decision": decision, "explanation": reason}) 265 + "calibrated_prob": round(prob, 4), "decision": decision, "explanation": reason, 266 + "profile": profiles.get(did)}) 115 267 return out 116 268 117 269 ··· 119 271 def metrics(con=Depends(get_con)): 120 272 """Aggregates for the dashboard (PRD 6.10 / 7.2). JSON only; the UI renders it.""" 121 273 er = _eigen(con) 274 + probs = _gate_probs(con, er) # the gate's real P(clean) per did (M5 if trained), not raw trust 122 275 dist = [0] * 10 123 - for t in er.trust.values(): 124 - dist[min(int(t * 10), 9)] += 1 276 + for p in probs.values(): 277 + dist[min(int(p * 10), 9)] += 1 125 278 126 279 decisions = {"fast_lane": 0, "normal_queue": 0, "needs_human": 0} 127 - for t in er.trust.values(): 128 - decisions[fusion.decide(t, None)] += 1 280 + for p in probs.values(): 281 + decisions[fusion.decide(p, None)] += 1 129 282 total = max(sum(decisions.values()), 1) 130 283 131 - # False-approval backtest: of historical PRs whose author is fast-lane-eligible 132 - # (structural >= T_HIGH), what fraction were NOT clean_merge? (PRD 6.8) 284 + # False-approval backtest (PRD 6.8): of historical PRs whose author is fast-lane-eligible 285 + # (gate prob >= T_HIGH), what fraction were NOT clean merges? Counted PER PR (not per 286 + # author-with-any-blemish, which over-counts prolific authors). This is an UPPER BOUND: 287 + # merge detection only confirmed 801/5768 merges, so most clean_merge=0 are merges we 288 + # couldn't CONFIRM (82% have no fetched diff), not bad PRs -- the true rate is lower. 289 + # Widen merge coverage (git-on-knots: more knots + Change-Id squash matching) to tighten it. 133 290 fa_total = fa_bad = 0 134 - for did, clean_rate in con.execute( 135 - "SELECT did, clean_merge_rate FROM features WHERE clean_merge_rate IS NOT NULL" 291 + for author_did, clean_merge in con.execute( 292 + "SELECT author_did, clean_merge FROM pr_labels WHERE clean_merge IS NOT NULL" 136 293 ).fetchall(): 137 - if er.trust.get(did, 0.0) >= CFG.gate.T_HIGH: 294 + if probs.get(author_did, er.trust.get(author_did, 0.0)) >= CFG.gate.T_HIGH: 138 295 fa_total += 1 139 - if clean_rate < 1.0: 296 + if clean_merge == 0: 140 297 fa_bad += 1 141 298 cur = con.execute("SELECT last_time_us FROM ingest_state WHERE stream='jetstream'").fetchone() 142 299 return { ··· 153 310 } 154 311 155 312 313 + @app.get("/backfill/status") 314 + def backfill_status(con=Depends(get_con)): 315 + """Live scrape progress: per-collection record counts in the raw `events` mirror, 316 + plus what derive() has typed-extracted. The page polls this every couple seconds.""" 317 + from .backfill import COLLECTIONS as expected 318 + 319 + counts = dict(con.execute( 320 + "SELECT collection, count(*) FROM events GROUP BY collection" 321 + ).fetchall()) 322 + # Show every planned collection (0 until reached), then any extras actually seen. 323 + collections = {c: counts.get(c, 0) for c in expected} 324 + for c, n in counts.items(): 325 + collections.setdefault(c, n) 326 + return { 327 + "collections": collections, 328 + "total": sum(counts.values()), 329 + "derived": { 330 + "contributors": con.execute("SELECT count(*) FROM contributors").fetchone()[0], 331 + "vouches": con.execute("SELECT count(*) FROM vouches").fetchone()[0], 332 + "pull_requests": con.execute("SELECT count(*) FROM pull_requests").fetchone()[0], 333 + }, 334 + } 335 + 336 + 156 337 # --- static surfaces ------------------------------------------------------- 157 338 @app.get("/") 158 339 def root(): 159 340 return FileResponse(STATIC / "triage.html") 341 + 342 + 343 + @app.get("/backfill") 344 + def backfill_page(): 345 + return FileResponse(STATIC / "backfill.html") 160 346 161 347 162 348 @app.get("/dashboard") ··· 169 355 return FileResponse(STATIC / "leaderboard.html") 170 356 171 357 358 + @app.get("/graph.html") 359 + def graph_page(): 360 + return FileResponse(STATIC / "graph.html") 361 + 362 + 172 363 app.mount("/static", StaticFiles(directory=STATIC), name="static") 173 364 174 365 175 366 def main() -> None: 176 367 import uvicorn 177 368 178 - uvicorn.run(app, host="127.0.0.1", port=8000) 369 + uvicorn.run(app, host="127.0.0.1", port=8003) 179 370 180 371 181 372 if __name__ == "__main__":
+75 -31
src/trust/backfill.py
··· 38 38 RELAY = "https://relay1.us-west.bsky.network" 39 39 PLC = "https://plc.directory" 40 40 41 - # Collections we know how to derive (must hit a COLLECTION_KINDS substring in config). 42 - COLLECTIONS = ["sh.tangled.repo.pull", "sh.tangled.graph.vouch", "sh.tangled.graph.follow"] 41 + # The WHOLE sh.tangled.* lexicon that actually holds records (live census via 42 + # listReposByCollection; git.refUpdate and bobbin came back empty, so they're omitted). 43 + # Every record is archived raw to `events`; derive() typed-extracts the subset it 44 + # knows (pull / vouch / follow), the rest just lives in the raw mirror. 45 + COLLECTIONS = [ 46 + "sh.tangled.repo.pull", "sh.tangled.repo.pull.status", "sh.tangled.graph.vouch", 47 + "sh.tangled.graph.follow", "sh.tangled.repo.issue", "sh.tangled.repo", 48 + "sh.tangled.actor.profile", "sh.tangled.feed.star", "sh.tangled.knot", 49 + "sh.tangled.knot.member", "sh.tangled.repo.collaborator", "sh.tangled.spindle.member", 50 + "sh.tangled.repo.artifact", "sh.tangled.pipeline", 51 + ] 52 + 53 + _PDS_CACHE: dict[str, str | None] = {} # DIDs recur across collections; resolve each once. 43 54 44 55 45 56 def _get(url: str, tries: int = 4) -> dict: ··· 58 69 59 70 60 71 def _pds(did: str) -> str | None: 61 - """DID -> PDS endpoint. Handles did:plc via PLC directory and did:web inline.""" 62 - if did.startswith("did:web:"): 63 - doc = _get(f"https://{did[len('did:web:'):]}/.well-known/did.json") 64 - else: 65 - doc = _get(f"{PLC}/{urllib.parse.quote(did)}") 66 - for s in doc.get("service", []): 67 - if s.get("type") == "AtprotoPersonalDataServer": 68 - return s["serviceEndpoint"].rstrip("/") 69 - return None 72 + """DID -> PDS endpoint (cached). Handles did:plc via PLC directory and did:web inline.""" 73 + if did in _PDS_CACHE: 74 + return _PDS_CACHE[did] 75 + endpoint = None 76 + try: 77 + if did.startswith("did:web:"): 78 + doc = _get(f"https://{did[len('did:web:'):]}/.well-known/did.json") 79 + else: 80 + doc = _get(f"{PLC}/{urllib.parse.quote(did)}") 81 + for s in doc.get("service", []): 82 + if s.get("type") == "AtprotoPersonalDataServer": 83 + endpoint = s["serviceEndpoint"].rstrip("/") 84 + break 85 + except Exception: 86 + endpoint = None 87 + _PDS_CACHE[did] = endpoint 88 + return endpoint 70 89 71 90 72 91 def _repos(collection: str): ··· 112 131 derive(con, buf) 113 132 114 133 115 - def backfill(collections=COLLECTIONS, max_repos: int | None = None) -> dict: 134 + def _fetch_repo(col: str, did: str) -> list[tuple] | None: 135 + """Network only (thread-safe): all of one repo's records as event tuples, or None on error. 136 + No DB access here, so WORKERS of these run concurrently without touching the single writer.""" 137 + try: 138 + pds = _pds(did) 139 + if not pds: 140 + return None 141 + out = [] 142 + for rec in _records(pds, did, col): 143 + # time_us=0: listRecords has no firehose seq; archive key is (did, collection, rkey). 144 + rkey = rec["uri"].rsplit("/", 1)[-1] 145 + out.append((did, 0, "create", col, rkey, json.dumps(rec["value"]))) 146 + return out 147 + except Exception: # a dead PDS / 400 must not abort the run 148 + return None 149 + 150 + 151 + def backfill(collections=COLLECTIONS, max_repos: int | None = None, workers: int = 12) -> dict: 152 + """Parallel scrape: a thread pool fetches repos concurrently (the slow network part), 153 + the main thread writes in chunks (DuckDB is single-writer; chunking also frees the file 154 + between flushes so the dashboard's read-only polls interleave). ponytail: ThreadPoolExecutor 155 + over a 12-wide pool, not asyncio — the work is I/O-bound and the GIL releases on socket waits.""" 156 + from concurrent.futures import ThreadPoolExecutor 157 + 116 158 ensure_schema() 117 159 counts: dict[str, int] = {} 118 160 for col in collections: 119 - repos = records = 0 120 - for did in _repos(col): 121 - if max_repos and repos >= max_repos: 122 - break 123 - repos += 1 124 - pds = _pds(did) 125 - if not pds: 126 - print(f"[backfill] {col} {did}: no PDS, skip") 127 - continue 128 - buf = [] 129 - for rec in _records(pds, did, col): 130 - # time_us=0: listRecords has no firehose seq; the archive key is the (did, collection, rkey). 131 - rkey = rec["uri"].rsplit("/", 1)[-1] 132 - buf.append((did, 0, "create", col, rkey, json.dumps(rec["value"]))) 133 - _archive_and_derive(buf) 134 - records += len(buf) 135 - print(f"[backfill] {col} {did} -> {len(buf)} records") 161 + dids = list(_repos(col)) 162 + if max_repos: 163 + dids = dids[:max_repos] 164 + records = errors = 0 165 + buf: list[tuple] = [] 166 + with ThreadPoolExecutor(max_workers=workers) as ex: 167 + for i, res in enumerate(ex.map(lambda d: _fetch_repo(col, d), dids), 1): 168 + if res is None: 169 + errors += 1 170 + else: 171 + buf.extend(res) 172 + records += len(res) 173 + if len(buf) >= 1000: # flush in chunks: one connection per ~1k records, not per repo 174 + _archive_and_derive(buf) 175 + buf = [] 176 + if i % 200 == 0: 177 + print(f"[backfill] {col}: {i}/{len(dids)} repos, {records} records ({errors} skipped)", flush=True) 178 + _archive_and_derive(buf) 136 179 counts[col] = records 137 - print(f"[backfill] {col}: {records} records from {repos} repos") 180 + print(f"[backfill] DONE {col}: {records} records from {len(dids)} repos ({errors} skipped)", flush=True) 138 181 return counts 139 182 140 183 ··· 157 200 ap.add_argument("--sample", action="store_true", help="print real records to confirm field shapes, write nothing") 158 201 ap.add_argument("--collection", default=None, help="restrict to one NSID (default: all known)") 159 202 ap.add_argument("--max-repos", type=int, default=None, help="cap repos per collection (smoke test)") 203 + ap.add_argument("--workers", type=int, default=12, help="concurrent repo fetchers (default 12)") 160 204 args = ap.parse_args() 161 205 if args.sample: 162 206 sample(args.collection or COLLECTIONS[0]) 163 207 return 164 208 cols = [args.collection] if args.collection else COLLECTIONS 165 - c = backfill(cols, max_repos=args.max_repos) 209 + c = backfill(cols, max_repos=args.max_repos, workers=args.workers) 166 210 print(f"[backfill] done: {c}") 167 211 168 212
+21 -1
src/trust/config.py
··· 57 57 COLLECTION_KINDS: dict[str, str] = { 58 58 "tangled.pull": "pull_request", 59 59 "tangled.repo.pull": "pull_request", 60 + # authoritative merge outcome (.merged/.closed/.open). MUST out-specific the pull rule 61 + # above; _kind() takes the longest matching needle so this wins over "tangled.repo.pull". 62 + "tangled.repo.pull.status": "pull_status", 60 63 "tangled.vouch": "vouch", 61 64 "tangled.graph.vouch": "vouch", 62 65 "tangled.denounce": "denounce", 63 66 "tangled.pipeline": "ci", 64 67 "tangled.spindle": "ci", 65 68 "tangled.issue": "issue", 66 - "tangled.star": "star", 69 + "tangled.feed.star": "star", # real NSID is sh.tangled.feed.star (".feed." breaks "tangled.star") 67 70 "tangled.attestation": "attestation", # jurisdiction attestation (6.13); CONFIRM NSID 68 71 "tangled.jurisdiction": "attestation", 69 72 "bsky.graph.follow": "follow", ··· 99 102 100 103 101 104 @dataclass 105 + class EmbedConfig: 106 + """Featherless (OpenAI-compatible) embeddings for the diff/slop-similarity path. 107 + Model + base_url are env-overridable so a renamed model never needs a code edit.""" 108 + 109 + model: str = os.environ.get("EMBED_MODEL", "Qwen/Qwen3-Embedding-4B") 110 + base_url: str = os.environ.get("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1") 111 + api_key_env: str = "FEATHERLESS_API_KEY" 112 + # MRL truncation: None -> model-native dim (Qwen3-Embedding-4B = 2560). Set to 113 + # store smaller vectors in DuckDB. Server ignores it if unsupported. 114 + dimensions: int | None = int(os.environ["EMBED_DIMENSIONS"]) if os.environ.get("EMBED_DIMENSIONS") else None 115 + batch: int = 32 # inputs per request 116 + max_chars: int = 24_000 # truncate giant diffs (matches the review token budget) 117 + timeout: float = 60.0 118 + 119 + 120 + @dataclass 102 121 class Config: 103 122 gate: GateConfig = field(default_factory=GateConfig) 104 123 eigen: EigenConfig = field(default_factory=EigenConfig) 105 124 review: ReviewConfig = field(default_factory=ReviewConfig) 125 + embed: EmbedConfig = field(default_factory=EmbedConfig) 106 126 clean_merge_window_days: int = 14 # PRD 6.3 label-mining N 107 127 108 128
+352
src/trust/content.py
··· 1 + """Tower B (content risk): a calibrated head on FROZEN diff embeddings (PRD Tier 1). 2 + 3 + Identity-blind by construction (PRD constraint 1): the only inputs are the diff 4 + embedding (diff_vectors, from the frozen Qwen3 transformer) and PR-intrinsic 5 + scalars (additions, deletions, files_touched, discussion_len). NO author 6 + DID/handle/history/aggregate ever enters this model -- leakage-free because the 7 + features simply do not contain identity. 8 + 9 + Trained on the clean_merge label with a time split; calibrated so the output is a 10 + real P(content safe). content_risk = 1 - P. Served only if it BEATS its baselines 11 + (majority + slop-kNN) on a time split AND a repo holdout -- same beat-the-baseline 12 + guardrail the GNN follows. fusion consults the verdict via load_if_winner(). 13 + 14 + Model: L2-normalize the embedding -> L2-regularized logistic regression (a linear 15 + probe, the correct choice for frozen embeddings at low data; a tree on the raw 16 + 2560-dim vector overfits) -> isotonic calibration on a held-out fold. 17 + 18 + Optional: needs `uv pip install -e '.[learned]'` (scikit-learn). fusion imports 19 + this lazily and treats an ImportError / unfit model as "signal unavailable". 20 + """ 21 + 22 + from __future__ import annotations 23 + 24 + import json 25 + import pickle 26 + 27 + import numpy as np 28 + 29 + from .config import MODEL_DIR 30 + from .db import connection 31 + from .learned import _reliability # reuse the PRD 6.8 reliability curve (predicted vs actual) 32 + 33 + MODEL_PATH = MODEL_DIR / "content.pkl" 34 + VERDICT = MODEL_DIR / "content_verdict.json" 35 + 36 + # PR-intrinsic scalars appended after the embedding. Size/shape of the CHANGE only -- 37 + # never identity. Order is fixed; it is the contract between _rows and serving. 38 + SCALAR_COLS = ["additions", "deletions", "files_touched", "discussion_len"] 39 + MIN_ROWS = 8 # hard floor: below this a linear probe is noise 40 + 41 + 42 + # --- feature construction (leakage-free: scaler fit on TRAIN only) ---------- 43 + 44 + def _l2(E: np.ndarray) -> np.ndarray: 45 + """Row-wise L2-normalize; zero rows stay zero (clamped denominator).""" 46 + return E / np.clip(np.linalg.norm(E, axis=1, keepdims=True), 1e-9, None) 47 + 48 + 49 + def _featurize_fit(emb, scal): 50 + """Fit the scalar standardizer on these rows; return (X, mean, std).""" 51 + E = _l2(np.asarray(emb, dtype=float)) 52 + S = np.log1p(np.asarray(scal, dtype=float).clip(min=0)) # size scalars: tame the heavy tail 53 + mean, std = S.mean(0), np.clip(S.std(0), 1e-9, None) 54 + return np.hstack([E, (S - mean) / std]), mean, std 55 + 56 + 57 + def _featurize_apply(emb, scal, mean, std): 58 + E = _l2(np.asarray(emb, dtype=float)) 59 + S = np.log1p(np.asarray(scal, dtype=float).clip(min=0)) 60 + return np.hstack([E, (S - mean) / std]) 61 + 62 + 63 + # --- data access ------------------------------------------------------------ 64 + 65 + def _rows(con): 66 + """(pr_id, embedding, additions, deletions, files_touched, discussion_len, 67 + clean_merge, opened_at, repo) for every PR with BOTH an embedding and a non-NULL 68 + clean_merge label, ordered by opened_at so [:k]/[k:] is a leakage-free time split.""" 69 + return con.execute( 70 + "SELECT v.pr_id, v.embedding, p.additions, p.deletions, p.files_touched, " 71 + " p.discussion_len, l.clean_merge, p.opened_at, p.repo " 72 + "FROM diff_vectors v " 73 + "JOIN pr_labels l USING (pr_id) " 74 + "JOIN pull_requests p ON p.pr_id = v.pr_id " 75 + "WHERE l.clean_merge IS NOT NULL " 76 + "ORDER BY p.opened_at" 77 + ).fetchall() 78 + 79 + 80 + def _matrix(rows): 81 + """rows -> (emb, scal, y, repos). y=1 is clean_merge (content safe).""" 82 + emb = [r[1] for r in rows] 83 + scal = [[r[2] or 0, r[3] or 0, r[4] or 0, r[5] or 0] for r in rows] 84 + y = np.array([int(r[6]) for r in rows], dtype=int) 85 + repos = [r[8] or "" for r in rows] 86 + return emb, scal, y, repos 87 + 88 + 89 + def _require(rows) -> None: 90 + classes = {int(r[6]) for r in rows} 91 + if len(rows) < MIN_ROWS or len(classes) < 2: 92 + raise SystemExit( 93 + f"content head needs >={MIN_ROWS} embedded+labelled PRs spanning both classes; " 94 + f"got {len(rows)} rows, classes={classes}. Run Phase 0 (trust.diffs), Phase 1 " 95 + f"(backfill sh.tangled.repo.pull.status for a positive class), Phase 2 " 96 + f"(trust.embed --build) first.") 97 + 98 + 99 + # --- the scorer ------------------------------------------------------------- 100 + 101 + class ContentScorer: 102 + """Serves P(content safe) / content_risk for a PR, identity-blind.""" 103 + 104 + def __init__(self, clf, iso, mean, std, emb_dim): 105 + self.clf, self.iso, self.mean, self.std, self.emb_dim = clf, iso, mean, std, emb_dim 106 + 107 + def _prob_safe(self, emb, scalars) -> float | None: 108 + if emb is None or len(emb) != self.emb_dim: # dim mismatch (different EMBED_DIMENSIONS) -> unavailable 109 + return None 110 + X = _featurize_apply([emb], [scalars], self.mean, self.std) 111 + raw = float(self.clf.predict_proba(X)[0, 1]) 112 + return float(self.iso.predict([raw])[0]) if self.iso is not None else raw 113 + 114 + def risk_for(self, emb, scalars) -> float | None: 115 + """content_risk from an embedding + scalars directly. None if dim mismatch.""" 116 + p = self._prob_safe(emb, scalars) 117 + return None if p is None else 1.0 - p 118 + 119 + def risk(self, con, pr_id: str | None = None, diff: str | None = None, 120 + scalars: list | None = None) -> float | None: 121 + """content_risk for a PR: prefer the stored embedding (cheap, every PR), else 122 + embed the diff live (serving a brand-new PR). None if neither is available 123 + (no embedding + no API key), so the gate treats content as simply absent.""" 124 + emb = None 125 + if pr_id is not None: 126 + row = con.execute("SELECT embedding FROM diff_vectors WHERE pr_id=?", [pr_id]).fetchone() 127 + emb = row[0] if row else None 128 + if emb is None and diff: 129 + from . import embed as embed_mod 130 + v = embed_mod.embed(diff) # None without FEATHERLESS_API_KEY 131 + emb = v[0] if v else None 132 + if emb is None: 133 + return None 134 + if scalars is None and pr_id is not None: 135 + srow = con.execute( 136 + f"SELECT {', '.join(SCALAR_COLS)} FROM pull_requests WHERE pr_id=?", [pr_id]).fetchone() 137 + scalars = [s or 0 for s in srow] if srow else [0, 0, 0, 0] 138 + return self.risk_for(emb, scalars or [0, 0, 0, 0]) 139 + 140 + def dump(self) -> dict: 141 + return {"clf": self.clf, "iso": self.iso, "mean": self.mean, "std": self.std, 142 + "emb_dim": self.emb_dim} 143 + 144 + 145 + # --- fit / train ------------------------------------------------------------ 146 + 147 + def _fit(emb, scal, y, split: float = 0.7): 148 + """Time-ordered emb/scalars/labels -> (ContentScorer, val_stats). Pure, no DB/IO. 149 + Scaler + model fit on the train fold only; isotonic calibrated on the val fold.""" 150 + from sklearn.isotonic import IsotonicRegression 151 + from sklearn.linear_model import LogisticRegression 152 + 153 + k = max(2, int(len(emb) * split)) 154 + Xtr, mean, std = _featurize_fit(emb[:k], scal[:k]) 155 + Xval = _featurize_apply(emb[k:], scal[k:], mean, std) 156 + ytr, yval = y[:k], y[k:] 157 + if len(set(ytr.tolist())) < 2: 158 + raise SystemExit("time-split train fold has a single class; need more history spanning both.") 159 + clf = LogisticRegression(C=1.0, class_weight="balanced", max_iter=1000).fit(Xtr, ytr) 160 + raw_val = clf.predict_proba(Xval)[:, 1] 161 + iso = (IsotonicRegression(out_of_bounds="clip", y_min=0.0, y_max=1.0).fit(raw_val, yval) 162 + if len(set(yval.tolist())) > 1 else None) # isotonic needs both classes in the holdout 163 + cal_val = iso.predict(raw_val) if iso is not None else raw_val 164 + scorer = ContentScorer(clf, iso, mean, std, len(emb[0])) 165 + return scorer, {"cal_val": np.asarray(cal_val), "yval": yval, "n_train": k, "n_val": len(yval)} 166 + 167 + 168 + def _save(scorer: ContentScorer) -> None: 169 + MODEL_DIR.mkdir(parents=True, exist_ok=True) 170 + MODEL_PATH.write_bytes(pickle.dumps(scorer.dump())) 171 + global _loaded, _cache 172 + _loaded, _cache = False, None # force reload of the fresh model 173 + 174 + 175 + def train(split: float = 0.7) -> dict: 176 + """Fit + calibrate + save content.pkl (Phase 3). Returns the reliability curve.""" 177 + with connection(read_only=True) as con: 178 + rows = _rows(con) 179 + _require(rows) 180 + emb, scal, y, _ = _matrix(rows) 181 + scorer, st = _fit(emb, scal, y, split) 182 + _save(scorer) 183 + return {"rows": len(rows), "train": st["n_train"], "val": st["n_val"], "emb_dim": scorer.emb_dim, 184 + "calibrated": scorer.iso is not None, 185 + "reliability": _reliability(st["cal_val"], st["yval"]), "model": str(MODEL_PATH)} 186 + 187 + 188 + # --- eval + beat-the-baseline gate (Phase 5) -------------------------------- 189 + 190 + def _auc(p_safe, y) -> float | None: 191 + """ROC-AUC of P(safe) vs the clean label. None if the fold has a single class.""" 192 + from sklearn.metrics import roc_auc_score 193 + if len(set(np.asarray(y).tolist())) < 2: 194 + return None 195 + return float(roc_auc_score(y, p_safe)) 196 + 197 + 198 + def _ap(p_safe, y) -> float | None: 199 + from sklearn.metrics import average_precision_score 200 + if len(set(np.asarray(y).tolist())) < 2: 201 + return None 202 + return float(average_precision_score(y, p_safe)) 203 + 204 + 205 + def _slop_baseline(emb_val, emb_train, y_train): 206 + """slop-kNN as a P(safe) proxy: 1 - nearest-cosine to a TRAIN known-bad diff 207 + (clean_merge=0). Leakage-free (train corpus only), no API (stored vectors).""" 208 + bad = [e for e, yy in zip(emb_train, y_train) if int(yy) == 0] 209 + if not bad: 210 + return None 211 + B = _l2(np.asarray(bad, dtype=float)) 212 + V = _l2(np.asarray(emb_val, dtype=float)) 213 + sim = (V @ B.T).max(axis=1) 214 + return 1.0 - np.clip(sim, 0.0, 1.0) 215 + 216 + 217 + def _eval_fold(train_rows, val_rows) -> dict | None: 218 + """Fit on train_rows, score val_rows. Returns model + slop-kNN AUC/AP on the val 219 + fold, or None if either fold lacks both classes (AUC undefined -> can't claim a win).""" 220 + emb_tr, scal_tr, ytr, _ = _matrix(train_rows) 221 + emb_va, scal_va, yva, _ = _matrix(val_rows) 222 + if len(set(ytr.tolist())) < 2 or len(set(yva.tolist())) < 2: 223 + return None 224 + from sklearn.linear_model import LogisticRegression 225 + Xtr, mean, std = _featurize_fit(emb_tr, scal_tr) 226 + clf = LogisticRegression(C=1.0, class_weight="balanced", max_iter=1000).fit(Xtr, ytr) 227 + p_safe = clf.predict_proba(_featurize_apply(emb_va, scal_va, mean, std))[:, 1] 228 + slop = _slop_baseline(emb_va, emb_tr, ytr) 229 + return {"auc": _auc(p_safe, yva), "ap": _ap(p_safe, yva), 230 + "slop_auc": (_auc(slop, yva) if slop is not None else None), 231 + "n_val": len(yva), "pos_val": int(yva.sum())} 232 + 233 + 234 + def _repo_split(rows, frac: float = 0.3): 235 + """Hold out whole repos (generalize to UNSEEN repos). Deterministic: the first 236 + ceil(frac*R) repos by name, so the holdout never overlaps the training repos.""" 237 + repos = sorted({r[8] or "" for r in rows}) 238 + held = set(repos[: max(1, int(len(repos) * frac + 0.999))]) 239 + train = [r for r in rows if (r[8] or "") not in held] 240 + val = [r for r in rows if (r[8] or "") in held] 241 + return train, val, held 242 + 243 + 244 + def _wins(ev) -> bool: 245 + """A fold is a win if the head beats majority (AUC>0.5) and slop-kNN (where the 246 + slop baseline is computable). A missing/single-class fold is NOT a win (conservative).""" 247 + if not ev or ev["auc"] is None: 248 + return False 249 + beats_majority = ev["auc"] > 0.5 250 + beats_slop = ev["slop_auc"] is None or ev["auc"] > ev["slop_auc"] 251 + return bool(beats_majority and beats_slop) 252 + 253 + 254 + def _jsonable(o): 255 + if isinstance(o, (np.floating, np.integer)): 256 + return float(o) 257 + return str(o) 258 + 259 + 260 + def train_and_compare(split: float = 0.7) -> dict: 261 + """Phase 5: fit + save the head, then write a verdict. content_wins is True only 262 + if it beats majority + slop-kNN on BOTH a time split and a repo holdout. fusion 263 + serves the head only when content_wins (load_if_winner).""" 264 + with connection(read_only=True) as con: 265 + rows = _rows(con) 266 + _require(rows) 267 + emb, scal, y, _ = _matrix(rows) 268 + scorer, st = _fit(emb, scal, y, split) 269 + _save(scorer) 270 + 271 + k = max(2, int(len(rows) * split)) 272 + time_eval = _eval_fold(rows[:k], rows[k:]) 273 + repo_tr, repo_va, held = _repo_split(rows) 274 + repo_eval = _eval_fold(repo_tr, repo_va) 275 + content_wins = bool(_wins(time_eval) and _wins(repo_eval)) 276 + 277 + verdict = {"rows": len(rows), "time_split": time_eval, "repo_holdout": repo_eval, 278 + "held_repos": len(held), "content_wins": content_wins, 279 + "reliability": _reliability(st["cal_val"], st["yval"])} 280 + MODEL_DIR.mkdir(parents=True, exist_ok=True) 281 + VERDICT.write_text(json.dumps(verdict, indent=2, default=_jsonable)) 282 + return verdict 283 + 284 + 285 + # --- serving (winner-gated, like gnn.load_if_winner) ------------------------ 286 + 287 + _cache: ContentScorer | None = None 288 + _loaded = False 289 + 290 + 291 + def load() -> ContentScorer | None: 292 + global _cache, _loaded 293 + if not _loaded: 294 + _loaded = True 295 + if MODEL_PATH.exists(): 296 + d = pickle.loads(MODEL_PATH.read_bytes()) 297 + _cache = ContentScorer(d["clf"], d["iso"], d["mean"], d["std"], d["emb_dim"]) 298 + return _cache 299 + 300 + 301 + def load_if_winner() -> ContentScorer | None: 302 + """Serving hook used by fusion: the head ONLY if it beat its baselines (else None, 303 + and the gate keeps Claude-only content -- never serve a model that didn't beat baseline).""" 304 + if not (VERDICT.exists() and MODEL_PATH.exists()): 305 + return None 306 + if not json.loads(VERDICT.read_text()).get("content_wins"): 307 + return None 308 + return load() 309 + 310 + 311 + def main() -> None: 312 + v = train_and_compare() 313 + ts, rh = v["time_split"] or {}, v["repo_holdout"] or {} 314 + print(f"[content] {v['rows']} labelled+embedded PRs") 315 + print(f"[content] time-split: AUC={ts.get('auc')} slop_auc={ts.get('slop_auc')} " 316 + f"n_val={ts.get('n_val')} pos={ts.get('pos_val')}") 317 + print(f"[content] repo-holdout ({v['held_repos']} repos): AUC={rh.get('auc')} " 318 + f"slop_auc={rh.get('slop_auc')} n_val={rh.get('n_val')} pos={rh.get('pos_val')}") 319 + print(f"[content] content_wins={v['content_wins']} -> " 320 + + ("SERVED (beats majority + slop-kNN on time AND repo holdout)" if v["content_wins"] 321 + else "NOT served; gate keeps Claude-only content (beat-the-baseline guardrail)")) 322 + print("[content] reliability (predicted vs actual P(safe)):") 323 + for b in v["reliability"]: 324 + print(f" {b['bin']} predicted={b['predicted']} actual={b['actual']} n={b['n']}") 325 + 326 + 327 + def demo() -> None: 328 + """Self-check (no DB/API): synthetic embeddings separable by label -> a held-out 329 + bad diff out-risks a clean one; print the reliability curve.""" 330 + rng = np.random.RandomState(0) # Math.random-free determinism; seeded numpy is fine 331 + D = 16 332 + emb, scal, y = [], [], [] 333 + for i in range(60): # time-ordered, classes alternate so both land in each fold 334 + clean = i % 2 335 + base = np.zeros(D); base[0] = 1.0 if clean else -1.0 336 + emb.append((base + rng.normal(0, 0.3, D)).tolist()) 337 + scal.append([rng.randint(1, 200), rng.randint(0, 100), rng.randint(1, 10), rng.randint(0, 500)]) 338 + y.append(clean) 339 + scorer, st = _fit(emb, scal, np.array(y), split=0.7) 340 + r_clean = scorer.risk_for([1.0] + [0.0] * (D - 1), [50, 10, 3, 100]) 341 + r_bad = scorer.risk_for([-1.0] + [0.0] * (D - 1), [50, 10, 3, 100]) 342 + print(f"content_risk: bad={r_bad:.3f} clean={r_clean:.3f}") 343 + for b in _reliability(st["cal_val"], st["yval"]): 344 + print(f" {b['bin']} predicted={b['predicted']} actual={b['actual']} n={b['n']}") 345 + assert scorer.risk_for([1.0] * D, [0, 0, 0, 0]) is not None 346 + assert scorer.risk_for([1.0] * (D + 1), [0, 0, 0, 0]) is None, "dim mismatch must be 'unavailable', not a crash" 347 + assert r_bad > r_clean, "content head must score a known-bad diff riskier than a clean one" 348 + print("ok") 349 + 350 + 351 + if __name__ == "__main__": 352 + demo()
+39 -8
src/trust/db.py
··· 38 38 pr_id VARCHAR PRIMARY KEY, reverted BOOLEAN DEFAULT FALSE, 39 39 patched_same_lines_within_n_days BOOLEAN DEFAULT FALSE 40 40 ); 41 + -- authoritative pull outcome from sh.tangled.repo.pull.status (public record). Separate 42 + -- table so a status arriving before its pull record (ordering not guaranteed) is never lost. 43 + CREATE TABLE IF NOT EXISTS pull_status ( 44 + pr_id VARCHAR PRIMARY KEY, status VARCHAR, updated_at TIMESTAMP DEFAULT now() 45 + ); 41 46 CREATE TABLE IF NOT EXISTS scores ( 42 47 did VARCHAR, as_of TIMESTAMP DEFAULT now(), structural_trust DOUBLE, 43 48 content_risk DOUBLE, calibrated_prob DOUBLE, decision VARCHAR, explanation_json JSON ··· 47 52 CREATE TABLE IF NOT EXISTS seeds (did VARCHAR PRIMARY KEY); 48 53 -- repo tiering (PRD 6.13): sensitive/dual-use repos gate fast-lane on an attestation 49 54 CREATE TABLE IF NOT EXISTS repo_tiers (repo VARCHAR PRIMARY KEY, tier VARCHAR DEFAULT 'public'); 55 + -- star graph (sh.tangled.feed.star): starrer -> repo owner. NOT sybil-resistant on its 56 + -- own (a star is cheap), so it's a model FEATURE, never a trust-graph edge. Keyed by 57 + -- (starrer, owner) so one DID endorsing an owner counts once, not once per repo. 58 + CREATE TABLE IF NOT EXISTS stars ( 59 + starrer_did VARCHAR, owner_did VARCHAR, created_at TIMESTAMP, 60 + PRIMARY KEY (starrer_did, owner_did) 61 + ); 50 62 -- contributor-issued jurisdiction attestations (signed records); declared, never inferred 51 63 CREATE TABLE IF NOT EXISTS attestations ( 52 64 did VARCHAR, jurisdiction VARCHAR, method VARCHAR, created_at TIMESTAMP, ··· 56 68 CREATE TABLE IF NOT EXISTS published_records ( 57 69 did VARCHAR, as_of TIMESTAMP, uri VARCHAR, PRIMARY KEY (did, as_of) 58 70 ); 71 + -- diff-embedding corpus (PRD 6.12 / section 4): near-duplicate detection of known-bad 72 + -- patterns. Vector search stays in DuckDB (list_cosine_similarity) -- no separate engine. 73 + CREATE TABLE IF NOT EXISTS diff_vectors (pr_id VARCHAR PRIMARY KEY, label VARCHAR, embedding DOUBLE[]); 59 74 """ 60 75 61 76 # Per-DID feature view (PRD 6.3/6.5). eigentrust_score + bsky_* are joined in ··· 65 80 WITH pr AS ( 66 81 SELECT p.*, COALESCE(f.reverted, FALSE) AS reverted, 67 82 COALESCE(f.patched_same_lines_within_n_days, FALSE) AS patched_quick, 68 - -- clean_merge label (PRD 6.3); NULL when too recent to have elapsed the N-day window 83 + -- merge outcome: pull_status (authoritative, public record) overrides the PDS 84 + -- record's merged field, which is always NULL on real sh.tangled.repo.pull. 85 + COALESCE(ps.status LIKE '%.merged', p.merged, FALSE) AS is_merged, 86 + COALESCE(ps.status LIKE '%.closed', p.closed_unmerged, FALSE) AS is_closed, 87 + -- clean_merge label (PRD 6.3); NULL when too recent to have elapsed the N-day window. 88 + -- CI relaxed: pass/fail isn't a public Tangled record, and a `merged` PR already 89 + -- cleared the merge-gate (which runs CI). So merged-and-not-CI-failed counts; only an 90 + -- explicit ci_status='failed' disqualifies. Tighten if a CI verdict source is wired. 69 91 CASE 70 92 WHEN p.opened_at > now() - INTERVAL {CFG.clean_merge_window_days} DAY THEN NULL 71 - WHEN p.merged AND p.ci_status = 'passed' 93 + WHEN COALESCE(ps.status LIKE '%.merged', p.merged, FALSE) 94 + AND COALESCE(p.ci_status, 'passed') <> 'failed' 72 95 AND NOT COALESCE(f.reverted, FALSE) 73 96 AND NOT COALESCE(f.patched_same_lines_within_n_days, FALSE) THEN 1 74 97 ELSE 0 75 98 END AS clean_merge 76 - FROM pull_requests p LEFT JOIN pr_followups f USING (pr_id) 99 + FROM pull_requests p 100 + LEFT JOIN pr_followups f USING (pr_id) 101 + LEFT JOIN pull_status ps USING (pr_id) 77 102 ) 78 103 SELECT 79 104 c.did, 80 105 date_diff('day', c.did_created_at, now()) AS did_age_days, 81 - COUNT(*) FILTER (WHERE pr.merged) AS merged_pr_count, 106 + COUNT(*) FILTER (WHERE pr.is_merged) AS merged_pr_count, 82 107 COALESCE(AVG(CASE WHEN pr.reverted THEN 1.0 ELSE 0.0 END), 0) AS revert_rate, 83 108 COALESCE(AVG(CASE WHEN pr.ci_status='passed' THEN 1.0 ELSE 0.0 END), 0) AS ci_pass_rate, 84 - COALESCE(AVG(CASE WHEN pr.closed_unmerged THEN 1.0 ELSE 0.0 END), 0) AS close_without_merge_ratio, 109 + COALESCE(AVG(CASE WHEN pr.is_closed THEN 1.0 ELSE 0.0 END), 0) AS close_without_merge_ratio, 85 110 COALESCE(AVG(pr.additions + pr.deletions), 0) AS mean_diff_size, 86 111 COALESCE(AVG(pr.files_touched), 0) AS mean_files_touched, 87 112 COALESCE(SUM(pr.additions + pr.deletions), 0) AS churn, 88 113 COALESCE(AVG(pr.discussion_len), 0) AS mean_discussion_len, 89 114 (SELECT COUNT(*) FROM vouches v WHERE v.subject_did = c.did AND v.polarity < 0) AS denounce_count, 115 + -- raw star count (advisory feature, gameable); the sybil-resistant trust-weighted 116 + -- version (stars_trust) is computed in Python and rides on the EigenResult. 117 + (SELECT COUNT(*) FROM stars st WHERE st.owner_did = c.did) AS stars_received, 90 118 AVG(pr.clean_merge) AS clean_merge_rate 91 119 FROM contributors c 92 120 LEFT JOIN pr ON pr.author_did = c.did ··· 100 128 SELECT p.pr_id, p.author_did, p.opened_at, 101 129 CASE 102 130 WHEN p.opened_at > now() - INTERVAL {CFG.clean_merge_window_days} DAY THEN NULL 103 - WHEN p.merged AND p.ci_status = 'passed' 131 + WHEN COALESCE(ps.status LIKE '%.merged', p.merged, FALSE) 132 + AND COALESCE(p.ci_status, 'passed') <> 'failed' 104 133 AND NOT COALESCE(f.reverted, FALSE) 105 134 AND NOT COALESCE(f.patched_same_lines_within_n_days, FALSE) THEN 1 106 135 ELSE 0 107 136 END AS clean_merge 108 - FROM pull_requests p LEFT JOIN pr_followups f USING (pr_id); 137 + FROM pull_requests p 138 + LEFT JOIN pr_followups f USING (pr_id) 139 + LEFT JOIN pull_status ps USING (pr_id); 109 140 """ 110 141 111 142 ··· 124 155 125 156 126 157 @contextmanager 127 - def connection(read_only: bool = False, attempts: int = 40, delay: float = 0.25): 158 + def connection(read_only: bool = False, attempts: int = 500, delay: float = 0.02): 128 159 """Short-lived connection with retry on DuckDB's cross-process file lock. 129 160 130 161 DuckDB allows only one read-write process; a held lock blocks every other
+150
src/trust/diffs.py
··· 1 + """Phase 0: fetch PR diffs (patchBlobs) into pull_requests.diff_text. 2 + 3 + The sh.tangled.repo.pull record carries its diff as a gzipped blob CID 4 + (rounds[-1].patchBlob.ref.$link), NOT inline. This resolves each pull's blob from 5 + the author's PDS, gunzips it to unified-diff text, and stores it -- the single 6 + highest-leverage unblock: it lights up the content head, Claude review, AND the 7 + slop-kNN, all of which are dead without diffs. 8 + 9 + Reuses backfill's _pds plumbing; the network fetch fans out over a thread pool, 10 + DB writes go in chunks (DuckDB is single-writer). Idempotent/resumable: only 11 + fetches pulls whose diff_text IS NULL, so re-running just resumes. Pause 12 + ingest/api/backfill first or the writes crawl on the single-writer lock. 13 + """ 14 + 15 + from __future__ import annotations 16 + 17 + import argparse 18 + import gzip 19 + import json 20 + import urllib.parse 21 + import urllib.request 22 + from concurrent.futures import ThreadPoolExecutor 23 + 24 + from .backfill import _pds 25 + from .db import connection, ensure_schema 26 + 27 + MAX_DIFF_CHARS = 50_000 # cap stored text; embeddings/Claude truncate well below this anyway 28 + MAX_BLOB_BYTES = 5_000_000 # a patch blob is normally < 100 KB; skip absurd ones, never OOM 29 + 30 + 31 + def _cid_for(record_json: str) -> str | None: 32 + """rounds[-1].patchBlob.ref.$link from an archived sh.tangled.repo.pull record. 33 + The LAST round is the final proposed change -- embed/review/store that one.""" 34 + try: 35 + rounds = (json.loads(record_json) or {}).get("rounds") or [] 36 + pb = (rounds[-1].get("patchBlob") if rounds else None) or {} 37 + return (pb.get("ref") or {}).get("$link") 38 + except Exception: 39 + return None 40 + 41 + 42 + def _get_blob(pds: str, did: str, cid: str) -> bytes | None: 43 + """com.atproto.sync.getBlob -> raw bytes (the gzipped patch). None on any error 44 + (a dead PDS / missing blob / 4xx must never abort the run).""" 45 + q = urllib.parse.urlencode({"did": did, "cid": cid}) 46 + url = f"{pds}/xrpc/com.atproto.sync.getBlob?{q}" 47 + try: 48 + req = urllib.request.Request(url, headers={"User-Agent": "trust-diffs"}) 49 + with urllib.request.urlopen(req, timeout=30) as r: 50 + return r.read(MAX_BLOB_BYTES + 1) # +1 so an oversized blob is detectable, not silently capped 51 + except Exception: 52 + return None 53 + 54 + 55 + def _diff_text(blob: bytes | None) -> str | None: 56 + """Gunzip a patch blob to unified-diff text, capped. None if empty, oversized, 57 + or not gzip/decodable (skip gracefully).""" 58 + if not blob or len(blob) > MAX_BLOB_BYTES: 59 + return None 60 + try: 61 + text = gzip.decompress(blob).decode("utf-8", "replace") 62 + except Exception: # bad magic (BadGzipFile/OSError), truncation (EOFError), or a corrupt deflate 63 + return None # body (zlib.error -- NOT an OSError) all skip this one blob, never abort the run 64 + return text[:MAX_DIFF_CHARS] or None 65 + 66 + 67 + def _fetch_one(work: tuple[str, str, str]) -> tuple[str, str] | None: 68 + """(pr_id, author_did, cid) -> (pr_id, diff_text), or None. Network only, so 69 + WORKERS of these run concurrently without touching the single DB writer.""" 70 + pr_id, did, cid = work 71 + pds = _pds(did) 72 + if not pds: 73 + return None 74 + text = _diff_text(_get_blob(pds, did, cid)) 75 + return (pr_id, text) if text else None 76 + 77 + 78 + def _store(buf: list[tuple[str, str]]) -> None: 79 + """Write a chunk of (pr_id, diff_text) under one short-lived write lock.""" 80 + if not buf: 81 + return 82 + with connection(read_only=False) as con: 83 + con.executemany("UPDATE pull_requests SET diff_text=? WHERE pr_id=?", 84 + [(text, pr_id) for pr_id, text in buf]) 85 + 86 + 87 + def fetch_diffs(limit: int | None = None, workers: int = 12, chunk: int = 200) -> dict: 88 + """Fetch+store diffs for every pull still missing one. The CID lives in the 89 + archived events.record (joined back to the pull by the derive() pr_id convention 90 + did||/||collection||/||rkey).""" 91 + ensure_schema() 92 + with connection(read_only=True) as con: 93 + rows = con.execute( 94 + "SELECT p.pr_id, p.author_did, e.record FROM pull_requests p " 95 + "JOIN events e ON p.pr_id = e.did || '/' || e.collection || '/' || e.rkey " 96 + "WHERE p.diff_text IS NULL AND e.collection = 'sh.tangled.repo.pull'" 97 + + (f" LIMIT {int(limit)}" if limit else "") 98 + ).fetchall() 99 + work = [(pr_id, did, cid) for pr_id, did, rec in rows if (cid := _cid_for(rec))] 100 + fetched = skipped = 0 101 + buf: list[tuple[str, str]] = [] 102 + with ThreadPoolExecutor(max_workers=workers) as ex: 103 + for i, res in enumerate(ex.map(_fetch_one, work), 1): 104 + if res is None: 105 + skipped += 1 106 + else: 107 + buf.append(res) 108 + fetched += 1 109 + if len(buf) >= chunk: 110 + _store(buf) 111 + buf = [] 112 + if i % 500 == 0: 113 + print(f"[diffs] {i}/{len(work)} pulls, {fetched} stored ({skipped} skipped)", flush=True) 114 + _store(buf) 115 + out = {"candidates": len(rows), "with_cid": len(work), "stored": fetched, "skipped": skipped} 116 + print(f"[diffs] DONE: {out}", flush=True) 117 + return out 118 + 119 + 120 + def demo() -> None: 121 + """Offline self-check: a gzipped unified diff round-trips through _diff_text (and 122 + a non-gzip blob is skipped, not crashed), and _cid_for pulls the CID from a pull 123 + record. No network -- the live fetch path is exercised by `python -m trust.diffs`.""" 124 + sample = "diff --git a/x.py b/x.py\n@@ -1 +1 @@\n-old\n+new\n" 125 + assert _diff_text(gzip.compress(sample.encode())) == sample, "gunzip round-trip failed" 126 + assert "@@" in sample and "diff" in sample 127 + assert _diff_text(b"not gzip at all") is None, "non-gzip blob must be skipped, not crash" 128 + assert _diff_text(b"") is None 129 + # valid gzip magic + garbage deflate body -> zlib.error (which is NOT an OSError); must skip, not crash. 130 + assert _diff_text(b"\x1f\x8b\x08\x00" + bytes(20)) is None, "corrupt deflate body must be skipped, not abort the run" 131 + rec = json.dumps({"rounds": [{"patchBlob": {"ref": {"$link": "bafyCID"}, "mimeType": "application/gzip"}}]}) 132 + assert _cid_for(rec) == "bafyCID", "CID extraction from pull record failed" 133 + assert _cid_for("{}") is None and _cid_for("not json") is None 134 + print("gunzip round-trip + CID parse ok") 135 + 136 + 137 + def main() -> None: 138 + ap = argparse.ArgumentParser(description="Fetch PR diff patchBlobs into pull_requests.diff_text") 139 + ap.add_argument("--limit", type=int, default=None, help="cap pulls fetched this run (smoke test)") 140 + ap.add_argument("--workers", type=int, default=12, help="concurrent blob fetchers (default 12)") 141 + ap.add_argument("--demo", action="store_true", help="run the offline self-check and exit") 142 + args = ap.parse_args() 143 + if args.demo: 144 + demo() 145 + return 146 + fetch_diffs(limit=args.limit, workers=args.workers) 147 + 148 + 149 + if __name__ == "__main__": 150 + main()
+14 -2
src/trust/eigentrust.py
··· 9 9 10 10 import math 11 11 from collections import deque 12 - from dataclasses import dataclass 12 + from dataclasses import dataclass, field 13 13 14 14 import numpy as np 15 15 from scipy import sparse ··· 23 23 index: dict[str, int] 24 24 seeds: list[str] 25 25 _adj: dict[str, list[str]] # positive-edge adjacency for BFS paths 26 + stars_trust: dict[str, float] = field(default_factory=dict) # owner -> Σ trust[starrer] (sybil-resistant stars) 26 27 27 28 def path_from_seed(self, did: str, max_hops: int = 4) -> list[str]: 28 29 """Shortest positive-vouch path seed -> did, for the explanation (PRD 6.4).""" ··· 112 113 113 114 hi = t.max() or 1.0 114 115 trust = {d: float(t[i] / hi) for d, i in index.items()} # max-normalize to [0,1] 115 - return EigenResult(trust, index, seeds, adj) 116 + 117 + # Trust-weighted stars: a star counts only as much as the starrer is itself trusted, 118 + # so sybil star-farms (trust ~0) contribute ~nothing. Turns a gameable popularity count 119 + # into a sybil-resistant reputation feature, same philosophy as the vouch graph. 120 + stars_trust: dict[str, float] = {} 121 + try: 122 + for owner, starrer in con.execute("SELECT owner_did, starrer_did FROM stars").fetchall(): 123 + stars_trust[owner] = stars_trust.get(owner, 0.0) + trust.get(starrer, 0.0) 124 + except Exception: 125 + pass # stars table absent on a pre-stars DB -> feature stays 0 until schema upgrades 126 + 127 + return EigenResult(trust, index, seeds, adj, stars_trust) 116 128 117 129 118 130 def demo() -> None:
+211
src/trust/embed.py
··· 1 + """Embeddings via the Featherless API (OpenAI-compatible) using Qwen3-Embedding-4B. 2 + 3 + Feeds the diff-embedding / slop-similarity path (PRD 6.x): embed a PR diff, then 4 + cosine-k-NN it against known-bad diffs. Returns None when no FEATHERLESS_API_KEY 5 + is set — exactly like review.py, so the caller treats it as "signal unavailable" 6 + rather than crashing. 7 + 8 + OpenAI-compatible, so this is a thin POST to /embeddings; no openai SDK needed. 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + import math 14 + import os 15 + 16 + from .config import CFG 17 + 18 + 19 + def _key() -> str | None: 20 + return os.environ.get(CFG.embed.api_key_env) 21 + 22 + 23 + def embed(texts: str | list[str], model: str | None = None) -> list[list[float]] | None: 24 + """Embed text(s) -> list of float vectors, input order preserved. 25 + 26 + Returns None if no API key is configured. Long inputs are truncated to 27 + CFG.embed.max_chars; sent in batches of CFG.embed.batch. 28 + """ 29 + if _key() is None: 30 + return None 31 + if isinstance(texts, str): 32 + texts = [texts] 33 + if not texts: 34 + return [] 35 + 36 + import httpx 37 + 38 + model = model or CFG.embed.model 39 + out: list[list[float]] = [] 40 + with httpx.Client(base_url=CFG.embed.base_url, timeout=CFG.embed.timeout, 41 + headers={"Authorization": f"Bearer {_key()}"}) as client: 42 + for i in range(0, len(texts), CFG.embed.batch): 43 + chunk = [t[: CFG.embed.max_chars] for t in texts[i : i + CFG.embed.batch]] 44 + body: dict = {"model": model, "input": chunk} 45 + if CFG.embed.dimensions: 46 + body["dimensions"] = CFG.embed.dimensions # MRL truncation, if the server honors it 47 + r = client.post("/embeddings", json=body) 48 + r.raise_for_status() 49 + # /embeddings does not guarantee order; sort by the returned index. 50 + data = sorted(r.json()["data"], key=lambda d: d["index"]) 51 + out.extend(d["embedding"] for d in data) 52 + return out 53 + 54 + 55 + def cosine(a: list[float], b: list[float]) -> float: 56 + """Cosine similarity. 0.0 if either vector is zero-length.""" 57 + dot = sum(x * y for x, y in zip(a, b)) 58 + na = math.sqrt(sum(x * x for x in a)) 59 + nb = math.sqrt(sum(y * y for y in b)) 60 + return dot / (na * nb) if na and nb else 0.0 61 + 62 + 63 + def _max_cosine(qv: list[float], vecs: list[list[float]]) -> float | None: 64 + """Nearest-neighbour similarity of qv to a corpus, clamped to [0,1]. None if empty.""" 65 + m = max((cosine(qv, v) for v in vecs), default=None) 66 + return None if m is None else max(0.0, min(1.0, m)) 67 + 68 + 69 + # --- diff/slop-similarity path (PRD 6.12) --------------------------------- 70 + # Embed every scraped PR diff once into diff_vectors; "known-bad" is decided at 71 + # query time by joining pr_labels (clean_merge=0), so re-labelling never needs a 72 + # re-embed. Search is a cosine scan in Python. ponytail: linear scan -- swap to 73 + # DuckDB's list_cosine_similarity / VSS HNSW index if the corpus grows large. 74 + 75 + 76 + def index_diffs(limit: int = 256) -> int: 77 + """Embed up to `limit` PR diffs not yet in diff_vectors (one pass). Idempotent 78 + and resumable: pr_id NOT IN diff_vectors means a re-run only embeds new diffs, 79 + so call it repeatedly while the scraper fills pull_requests. Returns the count 80 + embedded; 0 when caught up or no API key (signal stays absent, like review.py). 81 + 82 + Opens its own short-lived connections: read the batch, release, embed off-lock 83 + (the network call is slow), then take the write lock only for the insert -- so a 84 + concurrently-running scraper is never blocked while we wait on Featherless.""" 85 + if _key() is None: 86 + return 0 87 + from .db import connection 88 + 89 + with connection(read_only=True) as con: 90 + rows = con.execute( 91 + "SELECT pr_id, diff_text FROM pull_requests " 92 + "WHERE diff_text IS NOT NULL AND length(diff_text) > 0 " 93 + "AND pr_id NOT IN (SELECT pr_id FROM diff_vectors) LIMIT ?", 94 + [limit], 95 + ).fetchall() 96 + if not rows: 97 + return 0 98 + vecs = embed([d for _, d in rows]) # network call, NO db lock held 99 + if vecs is None: 100 + return 0 101 + with connection(read_only=False) as con: 102 + con.executemany( 103 + "INSERT INTO diff_vectors (pr_id, label, embedding) VALUES (?, 'pr', ?) " 104 + "ON CONFLICT (pr_id) DO UPDATE SET embedding = excluded.embedding", 105 + [[pr_id, v] for (pr_id, _), v in zip(rows, vecs)], 106 + ) 107 + return len(rows) 108 + 109 + 110 + def slop_score(con, diff: str, exclude_pr_id: str | None = None) -> float | None: 111 + """Similarity of `diff` to the nearest *currently* known-bad diff (clean_merge=0), 112 + in [0,1]. None if the diff is empty, no key is set, or nothing bad is embedded yet. 113 + Advisory only -- fed to Claude as a machine finding; never decides a PR on its own.""" 114 + if not diff: 115 + return None 116 + q = embed(diff) 117 + if not q: # None (no key) or empty 118 + return None 119 + sql = ("SELECT d.embedding FROM diff_vectors d JOIN pr_labels l USING (pr_id) " 120 + "WHERE l.clean_merge = 0") 121 + params: list = [] 122 + if exclude_pr_id: # a PR must not match itself 123 + sql += " AND d.pr_id <> ?" 124 + params.append(exclude_pr_id) 125 + vecs = [r[0] for r in con.execute(sql, params).fetchall()] 126 + return _max_cosine(q[0], vecs) 127 + 128 + 129 + def demo() -> None: 130 + """Offline: cosine identities. Live (key set): near-duplicate code embeds 131 + closer than unrelated prose.""" 132 + v = [1.0, 2.0, 3.0] 133 + assert abs(cosine(v, v) - 1.0) < 1e-9, "cosine(v,v) must be 1" 134 + assert abs(cosine([1.0, 0.0], [0.0, 1.0])) < 1e-9, "orthogonal -> 0" 135 + 136 + # slop-path ranking: a near-duplicate of a corpus vector outranks an unrelated one. 137 + corpus = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] 138 + assert _max_cosine([1.0, 0.05, 0.0], corpus) > 0.99, "near-dup -> ~1" 139 + assert _max_cosine([0.0, 0.0, 1.0], corpus) < 0.2, "unrelated -> low" 140 + assert _max_cosine([1.0], []) is None, "empty corpus -> None" 141 + 142 + if _key() is None: 143 + print(f"cosine ok; no {CFG.embed.api_key_env} -> live embedding skipped") 144 + return 145 + a, b, c = embed([ 146 + "def add(x, y): return x + y", 147 + "def sum_two(p, q): return p + q", 148 + "the cat sat quietly on the warm windowsill", 149 + ]) 150 + assert len(a) > 0, "empty embedding" 151 + assert cosine(a, b) > cosine(a, c), "near-duplicate code should embed closer than prose" 152 + print(f"dim={len(a)} sim(code,code)={cosine(a, b):.3f} > sim(code,prose)={cosine(a, c):.3f} ok") 153 + 154 + 155 + def main() -> None: 156 + import argparse 157 + import sys 158 + 159 + ap = argparse.ArgumentParser(description="Featherless/Qwen embeddings of scraped Tangled diffs") 160 + ap.add_argument("text", nargs="*", help="strings to embed; runs the self-check if omitted") 161 + ap.add_argument("--build", action="store_true", 162 + help="embed all PR diffs into diff_vectors (idempotent; safe to re-run as the scrape fills)") 163 + ap.add_argument("--chunk", type=int, default=256, help="diffs per pass / write-lock window") 164 + ap.add_argument("--limit", type=int, default=None, help="stop after this many this run (quick test)") 165 + ap.add_argument("--watch", action="store_true", 166 + help="keep embedding new diffs as the scraper adds them (sleep when caught up)") 167 + ap.add_argument("--interval", type=float, default=10.0, help="--watch poll seconds") 168 + args = ap.parse_args() 169 + if args.build: 170 + import time 171 + 172 + from .db import connection, ensure_schema 173 + 174 + ensure_schema() 175 + if _key() is None: 176 + print(f"[embed] no {CFG.embed.api_key_env} -> nothing embedded (slop signal stays absent)") 177 + return 178 + total = 0 179 + while True: 180 + # index_diffs manages its own short-lived connections (read -> embed off-lock -> write) 181 + n = index_diffs(limit=args.chunk) 182 + if n: 183 + total += n 184 + print(f"[embed] {total} diffs embedded", flush=True) 185 + if args.limit and total >= args.limit: 186 + break 187 + continue 188 + if args.watch: # caught up; wait for the scraper to add more 189 + time.sleep(args.interval) 190 + continue 191 + break 192 + with connection(read_only=True) as con: 193 + done, remaining = con.execute( 194 + "SELECT (SELECT count(*) FROM diff_vectors), " 195 + "(SELECT count(*) FROM pull_requests WHERE diff_text IS NOT NULL " 196 + " AND length(diff_text) > 0 AND pr_id NOT IN (SELECT pr_id FROM diff_vectors))" 197 + ).fetchone() 198 + print(f"[embed] done: +{total} this run; {done} embedded, {remaining} remaining ({CFG.embed.model})") 199 + return 200 + if not args.text: 201 + demo() 202 + return 203 + vecs = embed(args.text) 204 + if vecs is None: 205 + sys.exit(f"set {CFG.embed.api_key_env} to embed") 206 + for t, v in zip(args.text, vecs): 207 + print(f"[{len(v)}d] {v[:4]}... :: {t[:60]}") 208 + 209 + 210 + if __name__ == "__main__": 211 + main()
+73 -2
src/trust/fusion.py
··· 9 9 import json 10 10 11 11 from .config import CFG 12 - from . import eigentrust, review as review_mod 12 + from . import eigentrust, review as review_mod, vouchsafe 13 13 14 14 15 15 def decide(structural_trust: float, content: dict | None, cfg=CFG.gate, *, ··· 53 53 return structural_trust * (1.0 - content["content_risk"]) 54 54 55 55 56 + def _fold_content(content: dict | None, model_risk: float | None) -> dict | None: 57 + """Phase 4: fold the Tower B head risk into the content signal MONOTONICALLY. 58 + 59 + Combine model + Claude as max(model_risk, claude_risk): can raise risk, never lower 60 + it, so content still only penalizes (never lifts an untrusted DID). When Claude was 61 + skipped (content is None), the head alone synthesizes the content signal so the gate 62 + still sees content for this PR -- the head covers EVERY PR cheaply, unlike Claude.""" 63 + if model_risk is None: 64 + return content 65 + if content is None: 66 + return {"content_risk": model_risk, "review_recommended": False, "flags": [], 67 + "summary": f"content-head risk {model_risk:.2f} (no Claude review)"} 68 + return {**content, "content_risk": max(content["content_risk"], model_risk)} 69 + 70 + 56 71 def _scorer(): 57 72 """Load the M5 LightGBM scorer if trained AND lightgbm is installed; else None.""" 58 73 try: ··· 71 86 return gnn.load_if_winner() 72 87 73 88 89 + def _slop(con, diff, pr_id): 90 + """Best-effort diff/slop similarity (6.12). Optional path -- never fail a score on it 91 + (no key, empty corpus, or a network blip all collapse to None = signal unavailable).""" 92 + if not diff: 93 + return None 94 + try: 95 + from . import embed 96 + return embed.slop_score(con, diff, exclude_pr_id=pr_id) 97 + except Exception: 98 + return None 99 + 100 + 101 + def _content_head(con, pr_id, diff): 102 + """Tower B learned content risk for this PR (PRD Tier 1), identity-blind. Winner-gated: 103 + None unless the head beat its baselines (content.load_if_winner) and an embedding (or a 104 + live-embeddable diff) exists. Best-effort -- never fail a score on it. Unlike Claude, this 105 + covers EVERY PR cheaply, so the gate gets a content signal even when review is skipped.""" 106 + try: 107 + from . import content 108 + scorer = content.load_if_winner() 109 + if scorer is None: 110 + return None 111 + return scorer.risk(con, pr_id=pr_id, diff=diff) 112 + except Exception: 113 + return None 114 + 115 + 74 116 def structural_for(did, er: eigentrust.EigenResult, feats: dict | None): 75 117 """Calibrated P(clean) for the gate. Precedence: winning GNN (M6) -> LightGBM (M5) 76 118 -> raw EigenTrust (M3). The GNN is used only if it provably beat the baseline.""" ··· 99 141 top_factors.append(f"revert rate {feats['revert_rate']:.0%}") 100 142 if feats.get("denounce_count"): 101 143 top_factors.append(f"{int(feats['denounce_count'])} denounce(s)") 144 + if feats.get("stars_received"): # advisory popularity; trust-weighted version lives in the model 145 + top_factors.append(f"{int(feats['stars_received'])} star(s) received") 102 146 if model_factors: # M5 LightGBM TreeSHAP contributions (6.9) 103 147 for mf in model_factors: 104 148 top_factors.append(f"{mf['feature']} ({mf['contribution'] + 0.0:+.3f})") ··· 139 183 tier = repo_tier(con, repo) # 6.13 repo tiering 140 184 attested = is_attested(con, did) 141 185 sensitive = tier == "sensitive" 186 + slop = _slop(con, diff, pr_id) # 6.12 diff/slop similarity to known-bad (advisory) 187 + model_risk = _content_head(con, pr_id, diff) # Tower B head: content risk for ALL PRs (winner-gated) 188 + scan = vouchsafe.scan_diff(diff) # 6.12 static secret/SAST findings (advisory, redacted) 189 + machine = {"slop_similarity_to_known_bad": round(slop, 3)} if slop is not None else None 190 + if model_risk is not None: 191 + machine = (machine or {}) | {"content_head_risk": round(model_risk, 3)} 192 + if scan: 193 + machine = (machine or {}) | {"static_scan_findings": scan} 142 194 content = None 143 195 if run_review and should_review(structural, sensitive): 144 - content = review_mod.review_pr(diff or "", title=repo or "", discussion="") 196 + content = review_mod.review_pr(diff or "", title=repo or "", discussion="", 197 + machine_findings=machine) 198 + content = _fold_content(content, model_risk) # Phase 4: monotone fold, content only penalizes 145 199 146 200 decision = decide(structural, content, attestation_required=sensitive, attested=attested) 147 201 prob = displayed_prob(structural, content) 148 202 gate_note = ("sensitive-tier repo: a valid jurisdiction attestation is required before " 149 203 "fast-lane/merge (6.13)") if sensitive and not attested else None 150 204 reason = build_reason(did, structural, content, er, feats, model_factors, gate_note) 205 + if model_risk is not None: # Tower B factor, surfaced like the others (Phase 4) 206 + reason["content_head_risk"] = round(model_risk, 3) 207 + reason["top_factors"].append(f"content-head risk {model_risk:.0%}") 208 + if slop is not None: 209 + reason["slop_similarity"] = round(slop, 3) 210 + if slop >= 0.9: # advisory: surfaces for the human, never flips the gate (6.12) 211 + reason["top_factors"].append(f"diff {slop:.0%} similar to a known-bad pattern") 212 + if scan: # advisory: surfaces for the human even when review is skipped (6.12) 213 + reason["static_scan_findings"] = scan 214 + worst = min(scan, key=lambda f: ["critical", "high", "medium"].index(f["severity"])) 215 + reason["top_factors"].append( 216 + f"static scan: {worst['severity']} {worst['type']} in added lines (line {worst['line']})") 151 217 152 218 con.execute( 153 219 "INSERT INTO scores (did, structural_trust, content_risk, calibrated_prob, decision, explanation_json) " ··· 212 278 assert decide(0.95, risky) == "needs_human", "high-severity flag forces human" 213 279 assert decide(0.5, None) == "normal_queue" 214 280 assert displayed_prob(0.9, risky) < 0.9, "content risk must penalize, never lift" 281 + # Phase 4 fold: head risk can only raise the content signal, never lower it. 282 + assert _fold_content(None, 0.3)["content_risk"] == 0.3, "Claude skipped -> head synthesizes content" 283 + assert _fold_content(clean, 0.4)["content_risk"] == 0.4, "head raises a clean Claude verdict" 284 + assert _fold_content(risky, 0.1)["content_risk"] == 0.9, "head never lowers a risky Claude verdict" 285 + assert _fold_content(clean, None) is clean, "no head -> content untouched" 215 286 # 6.13: a sensitive-tier repo with no attestation forces human even for a perfect score. 216 287 assert decide(0.99, clean, attestation_required=True, attested=False) == "needs_human" 217 288 assert decide(0.99, clean, attestation_required=True, attested=True) == "fast_lane"
+48 -7
src/trust/ingest.py
··· 23 23 24 24 25 25 def _kind(collection: str) -> str | None: 26 + # Longest matching needle wins, so "tangled.repo.pull.status" maps to pull_status 27 + # rather than being swallowed by the shorter "tangled.repo.pull" -> pull_request. 28 + best, blen = None, -1 26 29 for needle, kind in COLLECTION_KINDS.items(): 27 - if needle in collection: 28 - return kind 29 - return None 30 + if needle in collection and len(needle) > blen: 31 + best, blen = kind, len(needle) 32 + return best 30 33 31 34 32 35 def _url(con) -> str: ··· 53 56 buf.clear() 54 57 55 58 59 + def _ts(v): 60 + """createdAt -> a value DuckDB's TIMESTAMP accepts, or NULL. Real records sometimes 61 + carry createdAt="" (or missing), which crashes the insert; coerce those to None.""" 62 + v = (v or "").strip() if isinstance(v, str) else v 63 + return v or None 64 + 65 + 56 66 def derive(con, events: list[tuple]) -> None: 57 - """Raw event tuples -> contributors / vouches / pull_requests (PRD 6.1, 6.2).""" 67 + """Raw event tuples -> contributors / vouches / pull_requests (PRD 6.1, 6.2). 68 + Per-record try/except: one malformed record is skipped, never aborts the batch.""" 58 69 for did, time_us, op, collection, rkey, record_json in events: 70 + try: 59 71 kind = _kind(collection) 60 72 rec = json.loads(record_json) if record_json else {} 61 73 con.execute( ··· 75 87 "VALUES (?,?,?,?,?,?,1.0) ON CONFLICT (voucher_did, subject_did) DO UPDATE SET " 76 88 "polarity=excluded.polarity, reason=excluded.reason", 77 89 [did, subject, polarity, rec.get("reason"), rec.get("evidence") or rec.get("uri"), 78 - rec.get("createdAt")], 90 + _ts(rec.get("createdAt"))], 79 91 ) 92 + elif kind == "pull_status" and op != "delete": 93 + # sh.tangled.repo.pull.status: authoritative outcome (.merged/.closed/.open), 94 + # the label signal absent from the pull record itself. It references the pull by 95 + # AT-URI and may be authored by a DIFFERENT did than the pull owner, so the pr_id 96 + # comes from the `pull` field (at://<did>/<coll>/<rkey> -> our pr_id), never from 97 + # this record's own did/rkey. Side table so it's insertion-order independent. 98 + pull_uri = rec.get("pull") or "" 99 + pr_id = pull_uri[len("at://"):] if pull_uri.startswith("at://") else None 100 + status = rec.get("status") 101 + if pr_id and status: 102 + con.execute( 103 + "INSERT INTO pull_status (pr_id, status, updated_at) VALUES (?,?,now()) " 104 + "ON CONFLICT (pr_id) DO UPDATE SET status=excluded.status, updated_at=now()", 105 + [pr_id, status], 106 + ) 107 + elif kind == "star" and op != "delete": 108 + # sh.tangled.feed.star: subject.did is the starred repo's OWNER; the record 109 + # author (did) is the starrer. A star is cheap -> feature only, never a trust 110 + # edge. PK (starrer, owner) dedups multi-repo stars; skip self-stars. 111 + subj = rec.get("subject") if isinstance(rec.get("subject"), dict) else {} 112 + owner = subj.get("did") 113 + if owner and owner != did: 114 + con.execute( 115 + "INSERT INTO stars (starrer_did, owner_did, created_at) VALUES (?,?,?) " 116 + "ON CONFLICT (starrer_did, owner_did) DO NOTHING", 117 + [did, owner, _ts(rec.get("createdAt"))], 118 + ) 80 119 elif kind == "attestation" and op != "delete": # 6.13 jurisdiction attestation 81 120 con.execute( 82 121 "INSERT INTO attestations (did, jurisdiction, method, created_at) VALUES (?,?,?,?) " 83 122 "ON CONFLICT (did, jurisdiction) DO NOTHING", 84 - [did, rec.get("jurisdiction"), rec.get("method", "signed_record"), rec.get("createdAt")], 123 + [did, rec.get("jurisdiction"), rec.get("method", "signed_record"), _ts(rec.get("createdAt"))], 85 124 ) 86 125 elif kind == "pull_request" and op != "delete": 87 126 pr_id = f"{did}/{collection}/{rkey}" ··· 97 136 "merged, closed_unmerged, additions, deletions, files_touched, diff_text, discussion_len) " 98 137 "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (pr_id) DO NOTHING", 99 138 [pr_id, did, tgt.get("repo") or rec.get("repo"), tgt.get("branch") or rec.get("target"), 100 - rec.get("createdAt"), rec.get("ciStatus"), rec.get("merged"), False, 139 + _ts(rec.get("createdAt")), rec.get("ciStatus"), rec.get("merged"), False, 101 140 rec.get("additions"), rec.get("deletions"), rec.get("filesTouched"), 102 141 rec.get("diff"), len(json.dumps(rec.get("body", "")))], 103 142 ) 143 + except Exception as e: # skip a single malformed record; never abort the batch 144 + print(f"[derive] skip {collection} {rkey}: {type(e).__name__}", flush=True) 104 145 105 146 106 147 def _flush(buf: list[tuple]) -> None:
+9 -1
src/trust/learned.py
··· 26 26 "eigentrust_score", "did_age_days", "merged_pr_count", "revert_rate", "ci_pass_rate", 27 27 "close_without_merge_ratio", "mean_diff_size", "mean_files_touched", "churn", 28 28 "mean_discussion_len", "denounce_count", 29 + "stars_received", "stars_trust", # popularity (raw, gameable) + trust-weighted (sybil-resistant) 29 30 ] 30 31 MODEL_PATH = MODEL_DIR / "learned.pkl" 31 32 33 + # Features sourced from the EigenResult (Python), not the SQL features view. 34 + _FROM_ER = { 35 + "eigentrust_score": lambda did, er: er.trust.get(did, 0.0), 36 + "stars_trust": lambda did, er: er.stars_trust.get(did, 0.0), 37 + } 38 + 32 39 33 40 def _vec(did: str, feats: dict, er: eigentrust.EigenResult) -> list[float]: 34 41 out = [] 35 42 for c in FEATURE_COLS: 36 - out.append(er.trust.get(did, 0.0) if c == "eigentrust_score" else float(feats.get(c) or 0.0)) 43 + src = _FROM_ER.get(c) 44 + out.append(src(did, er) if src else float(feats.get(c) or 0.0)) 37 45 return out 38 46 39 47
+235
src/trust/merged.py
··· 1 + """Phase 1 backstop: git-on-knots merge detection. 2 + 3 + `sh.tangled.repo.pull.status` is published for almost no PRs (41 network-wide), so 4 + merge truth lives on the knots. Each PR carries a `git format-patch` (the patchBlob); 5 + a PR is MERGED if its patch-id matches a commit on the target repo's default branch. 6 + So: clone each target repo bare once, patch-id every commit on HEAD, match all that 7 + repo's PRs against the set, store labels, delete the clone. 8 + 9 + Writes `pull_requests.merged=TRUE` for matches (the schema's intended backstop column; 10 + the real `merged` field is always NULL on sh.tangled.repo.pull). Also stores the 11 + decompressed patch into `diff_text` while it's in hand (the Phase-0 fetch, for free). 12 + 13 + ponytail: patch-id matching is rebase-tolerant but squash-blind -- a squash/heavily-edited 14 + merge shows up as a false negative. The patches carry a Gerrit-style `Change-Id:`; if recall 15 + proves too low, match on that instead (survives squash). Idempotent: re-running just 16 + recomputes; the UPDATEs are harmless to repeat. 17 + """ 18 + 19 + from __future__ import annotations 20 + 21 + import argparse 22 + import gzip 23 + import json 24 + import shutil 25 + import re 26 + import subprocess 27 + import tempfile 28 + from concurrent.futures import ThreadPoolExecutor 29 + 30 + from .backfill import _pds 31 + from .config import STAGING_DIR 32 + from .db import connection, ensure_schema 33 + from .diffs import MAX_BLOB_BYTES, MAX_DIFF_CHARS, _cid_for, _get_blob 34 + 35 + CLONE_TIMEOUT = 180 # seconds; skip a pathologically huge/slow repo rather than hang the run 36 + 37 + 38 + def _patch_id(patch: bytes) -> str | None: 39 + """git patch-id --stable -> the leading hash, or None. Same algorithm both sides 40 + (PR patch and branch commits) is all that matters for comparison.""" 41 + try: 42 + out = subprocess.run(["git", "patch-id", "--stable"], input=patch, 43 + capture_output=True, timeout=30).stdout.decode().split() 44 + return out[0] if out else None 45 + except Exception: 46 + return None 47 + 48 + 49 + _CHANGE_ID = re.compile(r"Change-Id:\s*(\S+)") 50 + 51 + 52 + def _change_id(patch: str) -> str | None: 53 + """The Gerrit-style Change-Id in a format-patch's message, if any. Survives rebase 54 + AND squash (git keeps the trailer), so it catches merges that patch-id misses.""" 55 + m = _CHANGE_ID.search(patch) 56 + return m.group(1) if m else None 57 + 58 + 59 + def _branch_keys(bare: str) -> tuple[set[str], set[str]]: 60 + """(patch_ids, change_ids) over every non-merge commit reachable from HEAD. 61 + Two independent fingerprints: patch-id matches exact/rebased patches; Change-Id also 62 + matches squashed/amended ones (where the diff changed but the trailer was preserved).""" 63 + log = subprocess.run(["git", "-C", bare, "log", "-p", "--no-merges", "HEAD"], 64 + capture_output=True, timeout=CLONE_TIMEOUT).stdout 65 + pid = subprocess.run(["git", "patch-id", "--stable"], input=log, 66 + capture_output=True, timeout=CLONE_TIMEOUT).stdout.decode() 67 + patch_ids = {ln.split()[0] for ln in pid.splitlines() if ln.split()} 68 + change_ids = set(_CHANGE_ID.findall(log.decode("utf-8", "replace"))) 69 + return patch_ids, change_ids 70 + 71 + 72 + def _full_patch(did: str, cid: str) -> str | None: 73 + """Decompressed patch text, UNCAPPED (patch-id needs the whole diff to match).""" 74 + pds = _pds(did) 75 + if not pds: 76 + return None 77 + blob = _get_blob(pds, did, cid) 78 + if not blob or len(blob) > MAX_BLOB_BYTES: 79 + return None 80 + try: 81 + return gzip.decompress(blob).decode("utf-8", "replace") or None 82 + except Exception: 83 + return None 84 + 85 + 86 + def _candidates(con) -> dict[str, list[str]]: 87 + """repoDid -> clone URLs `https://{knot}/{owner_did}/{name}` (non-localhost), deduped. 88 + A repoDid can have several repo records (re-registrations/forks); try them in order.""" 89 + out: dict[str, list[str]] = {} 90 + for did, rkey, rec in con.execute( 91 + "SELECT did, rkey, record FROM events WHERE collection='sh.tangled.repo'").fetchall(): 92 + v = json.loads(rec) 93 + rd, knot = v.get("repoDid"), (v.get("knot") or "") 94 + if not rd or knot.startswith("localhost"): 95 + continue 96 + url = f"https://{knot}/{did}/{rkey}" 97 + out.setdefault(rd, []) 98 + if url not in out[rd]: 99 + out[rd].append(url) 100 + return out 101 + 102 + 103 + def _targets(con) -> dict[str, list[tuple[str, str, str]]]: 104 + """repoDid -> [(pr_id, author_did, patch_cid)] for every pull targeting it.""" 105 + out: dict[str, list[tuple[str, str, str]]] = {} 106 + for did, rkey, rec in con.execute( 107 + "SELECT did, rkey, record FROM events WHERE collection='sh.tangled.repo.pull'").fetchall(): 108 + v = json.loads(rec) 109 + t = v.get("target") or {} 110 + rd = t.get("repoDid") or t.get("repo") 111 + cid = _cid_for(rec) 112 + if not rd or not cid: 113 + continue 114 + pr_id = f"{did}/sh.tangled.repo.pull/{rkey}" 115 + out.setdefault(rd, []).append((pr_id, did, cid)) 116 + return out 117 + 118 + 119 + def _clone(url: str, dest: str) -> bool: 120 + try: 121 + subprocess.run(["git", "clone", "--bare", "--single-branch", url, dest], 122 + capture_output=True, timeout=CLONE_TIMEOUT, 123 + env={"GIT_TERMINAL_PROMPT": "0"}, check=True) 124 + return True 125 + except Exception: 126 + return False 127 + 128 + 129 + def _process(repodid: str, urls: list[str], prs: list[tuple[str, str, str]]) -> dict: 130 + """Clone the repo, match each of its PRs, return {merged:set, diffs:[(pr_id,text)]}. 131 + Always removes its clone before returning, so concurrent repos bound disk use.""" 132 + tmp = tempfile.mkdtemp(dir=str(STAGING_DIR), prefix="merged-") 133 + bare = f"{tmp}/repo.git" 134 + try: 135 + if not any(_clone(u, bare) for u in urls): 136 + return {"merged": set(), "diffs": [], "cloned": False} 137 + branch_pids, branch_cids = _branch_keys(bare) 138 + merged, diffs = set(), [] 139 + for pr_id, did, cid in prs: 140 + patch = _full_patch(did, cid) 141 + if not patch: 142 + continue 143 + diffs.append((pr_id, patch[:MAX_DIFF_CHARS])) # Phase-0 freebie (capped) 144 + pid = _patch_id(patch.encode()) 145 + chid = _change_id(patch) # squash/rebase-proof fallback 146 + if (pid and pid in branch_pids) or (chid and chid in branch_cids): 147 + merged.add(pr_id) 148 + return {"merged": merged, "diffs": diffs, "cloned": True} 149 + except Exception: 150 + return {"merged": set(), "diffs": [], "cloned": False} 151 + finally: 152 + shutil.rmtree(tmp, ignore_errors=True) 153 + 154 + 155 + def _write(merged: set[str], diffs: list[tuple[str, str]]) -> bool: 156 + """One write lock: merge labels + (any still-missing) diff_text. Patient retry 157 + (~120s) rides out a concurrent score-loop writer instead of crashing the run; if 158 + it still can't get the lock, the chunk is dropped (logged) and an idempotent 159 + re-run recovers it -- never abort 600 repos of work over one lock loss.""" 160 + if not merged and not diffs: 161 + return True 162 + try: 163 + with connection(read_only=False, attempts=480, delay=0.25) as con: 164 + if merged: 165 + con.executemany("UPDATE pull_requests SET merged=TRUE WHERE pr_id=?", 166 + [(p,) for p in merged]) 167 + if diffs: 168 + con.executemany( # diffs is (pr_id, text); the UPDATE binds (text, pr_id) 169 + "UPDATE pull_requests SET diff_text=? WHERE pr_id=? AND diff_text IS NULL", 170 + [(text, pid) for pid, text in diffs]) 171 + return True 172 + except Exception as e: 173 + print(f"[merged] WRITE LOST to lock ({len(merged)} labels, {len(diffs)} diffs) -- " 174 + f"re-run to recover: {e}", flush=True) 175 + return False 176 + 177 + 178 + def detect(max_repos: int | None = None, workers: int = 6) -> dict: 179 + """Clone every target repo, patch-id match its PRs, write merge labels. 180 + Repos run concurrently (network+disk+git); DB writes funnel through the main thread.""" 181 + ensure_schema() 182 + with connection(read_only=True) as con: 183 + cands, targets = _candidates(con), _targets(con) 184 + work = [(rd, cands[rd], prs) for rd, prs in targets.items() if rd in cands] 185 + if max_repos: 186 + work = work[:max_repos] 187 + skipped_unresolved = sum(len(prs) for rd, prs in targets.items() if rd not in cands) 188 + print(f"[merged] {len(work)} resolvable repos, " 189 + f"{sum(len(p) for _,_,p in work)} PRs ({skipped_unresolved} PRs have no clone URL)", flush=True) 190 + 191 + total_merged = total_diffs = repos_failed = writes_lost = 0 192 + with ThreadPoolExecutor(max_workers=workers) as ex: 193 + for i, res in enumerate(ex.map(lambda w: _process(*w), work), 1): 194 + if not res["cloned"]: 195 + repos_failed += 1 196 + if _write(res["merged"], res["diffs"]): 197 + total_merged += len(res["merged"]) 198 + total_diffs += len(res["diffs"]) 199 + else: 200 + writes_lost += 1 201 + if i % 25 == 0: 202 + print(f"[merged] {i}/{len(work)} repos, {total_merged} merged labels, " 203 + f"{total_diffs} diffs ({repos_failed} unreachable, {writes_lost} writes lost)", flush=True) 204 + out = {"repos": len(work), "merged": total_merged, "diffs_stored": total_diffs, 205 + "repos_failed": repos_failed, "writes_lost": writes_lost, "prs_unresolved": skipped_unresolved} 206 + print(f"[merged] DONE: {out}", flush=True) 207 + return out 208 + 209 + 210 + def demo() -> None: 211 + """Offline self-check: a patch-id is stable for the same diff and differs for a 212 + changed diff, so set-membership matching is sound. No network/clone.""" 213 + d1 = b"diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n" 214 + d2 = b"diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+c\n" 215 + p1, p1b, p2 = _patch_id(d1), _patch_id(d1), _patch_id(d2) 216 + assert p1 and p1 == p1b, "patch-id not stable for identical diff" 217 + assert p1 != p2, "different diffs must yield different patch-ids" 218 + assert p1 in {p1, "deadbeef"} and p2 not in {p1}, "membership logic wrong" 219 + print("patch-id stable + discriminating ok") 220 + 221 + 222 + def main() -> None: 223 + ap = argparse.ArgumentParser(description="git-on-knots merge detection -> pull_requests.merged") 224 + ap.add_argument("--max-repos", type=int, default=None, help="cap repos processed (smoke test)") 225 + ap.add_argument("--workers", type=int, default=6, help="concurrent repo clones (default 6)") 226 + ap.add_argument("--demo", action="store_true", help="offline self-check, then exit") 227 + args = ap.parse_args() 228 + if args.demo: 229 + demo() 230 + return 231 + detect(max_repos=args.max_repos, workers=args.workers) 232 + 233 + 234 + if __name__ == "__main__": 235 + main()
+85
src/trust/static/backfill.html
··· 1 + <!doctype html> 2 + <meta charset="utf-8"> 3 + <title>Tangled backfill — live</title> 4 + <style> 5 + :root { color-scheme: dark; } 6 + body { margin: 0; background: #0d1117; color: #e6edf3; 7 + font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; } 8 + header { padding: 18px 24px; border-bottom: 1px solid #21262d; display: flex; 9 + align-items: baseline; gap: 16px; flex-wrap: wrap; } 10 + h1 { font-size: 16px; margin: 0; font-weight: 600; } 11 + .dot { width: 9px; height: 9px; border-radius: 50%; background: #3fb950; 12 + display: inline-block; box-shadow: 0 0 8px #3fb950; animation: pulse 1.5s infinite; } 13 + @keyframes pulse { 50% { opacity: .35; } } 14 + .total { font-size: 28px; font-weight: 700; color: #58a6ff; } 15 + .rate { color: #8b949e; } 16 + main { padding: 16px 24px; max-width: 920px; } 17 + .row { display: grid; grid-template-columns: 230px 1fr 90px; align-items: center; 18 + gap: 12px; margin: 6px 0; } 19 + .name { color: #c9d1d9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 20 + .name.active { color: #3fb950; } 21 + .bar { background: #161b22; border-radius: 4px; height: 20px; overflow: hidden; } 22 + .fill { height: 100%; background: linear-gradient(90deg, #1f6feb, #58a6ff); 23 + width: 0; transition: width .4s ease; } 24 + .fill.active { background: linear-gradient(90deg, #238636, #3fb950); } 25 + .num { text-align: right; color: #e6edf3; } 26 + .zero .name { color: #6e7681; } .zero .num { color: #6e7681; } 27 + footer { padding: 12px 24px; color: #8b949e; border-top: 1px solid #21262d; } 28 + .derived span { margin-right: 18px; } .derived b { color: #e6edf3; } 29 + .err { color: #f85149; } 30 + </style> 31 + <header> 32 + <a href="/" style="display:block;width:100%;margin-bottom:8px"><img src="/static/logo.svg" alt="CyberCred" height="24"></a> 33 + <span class="dot" id="dot"></span> 34 + <h1>Tangled backfill</h1> 35 + <span class="total" id="total">—</span> 36 + <span class="rate" id="rate"></span> 37 + <span class="rate" id="stamp"></span> 38 + </header> 39 + <main id="rows"></main> 40 + <footer class="derived" id="derived"></footer> 41 + <script> 42 + let prevTotal = null, prevT = null, peak = 1; 43 + const fmt = n => n.toLocaleString(); 44 + 45 + async function tick() { 46 + let d; 47 + try { 48 + d = await (await fetch('/backfill/status', {cache: 'no-store'})).json(); 49 + } catch (e) { 50 + document.getElementById('stamp').innerHTML = '<span class="err">offline — is the API up?</span>'; 51 + return; 52 + } 53 + const now = Date.now(); 54 + document.getElementById('total').textContent = fmt(d.total); 55 + if (prevTotal !== null && now > prevT) { 56 + const rps = (d.total - prevTotal) / ((now - prevT) / 1000); 57 + document.getElementById('rate').textContent = rps >= 0.5 ? `+${rps.toFixed(0)}/s` : ''; 58 + document.getElementById('dot').style.opacity = rps >= 0.5 ? 1 : 0.3; 59 + } 60 + document.getElementById('stamp').textContent = new Date().toLocaleTimeString(); 61 + 62 + const entries = Object.entries(d.collections); 63 + peak = Math.max(peak, ...entries.map(([, n]) => n), 1); 64 + const rows = document.getElementById('rows'); 65 + rows.innerHTML = entries.map(([name, n]) => { 66 + const active = window._prev && window._prev[name] !== undefined && n > window._prev[name]; 67 + const w = (n / peak * 100).toFixed(1); 68 + return `<div class="row ${n === 0 ? 'zero' : ''}"> 69 + <div class="name ${active ? 'active' : ''}">${active ? '▸ ' : ''}${name}</div> 70 + <div class="bar"><div class="fill ${active ? 'active' : ''}" style="width:${w}%"></div></div> 71 + <div class="num">${fmt(n)}</div></div>`; 72 + }).join(''); 73 + window._prev = d.collections; 74 + 75 + const x = d.derived; 76 + document.getElementById('derived').innerHTML = 77 + `derived &rarr; <span>contributors <b>${fmt(x.contributors)}</b></span>` + 78 + `<span>vouches <b>${fmt(x.vouches)}</b></span>` + 79 + `<span>pull_requests <b>${fmt(x.pull_requests)}</b></span>`; 80 + 81 + prevTotal = d.total; prevT = now; 82 + } 83 + tick(); 84 + setInterval(tick, 1500); 85 + </script>
+2 -1
src/trust/static/dashboard.html
··· 19 19 </head> 20 20 <body> 21 21 <header> 22 + <a href="/" style="display:block;margin-bottom:8px"><img src="/static/logo.svg" alt="CyberCred" height="24"></a> 22 23 <h1>Observability dashboard</h1> 23 - <nav><a href="/">Triage</a><a href="/dashboard">Dashboard</a><a href="/leaderboard.html">Leaderboard</a></nav> 24 + <nav><a href="/">Triage</a><a href="/dashboard">Dashboard</a><a href="/leaderboard.html">Leaderboard</a><a href="/graph.html">Graph</a></nav> 24 25 </header> 25 26 <main id="main">loading…</main> 26 27 <script>
+115
src/trust/static/graph.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 5 + <title>Graph · Tangled trust</title> 6 + <style> 7 + html,body { margin:0; height:100%; overflow:hidden; background:#0d1117; color:#e6edf3; 8 + font:14px/1.5 system-ui,sans-serif; } 9 + header { position:fixed; inset:0 0 auto 0; z-index:2; padding:14px 24px; display:flex; 10 + align-items:baseline; gap:18px; flex-wrap:wrap; border-bottom:1px solid #21262d; 11 + background:rgba(13,17,23,.7); backdrop-filter:blur(6px); } 12 + h1 { margin:0; font-size:16px; font-weight:600; } 13 + nav a { margin-right:14px; color:#58a6ff; text-decoration:none; } 14 + .legend { margin-left:auto; color:#8b949e; font-size:12px; } 15 + .legend i { display:inline-block; width:9px; height:9px; border-radius:50%; margin:0 4px 0 12px; vertical-align:middle; } 16 + #graph { position:absolute; inset:0; } 17 + #panel { position:fixed; top:0; right:0; bottom:0; width:320px; z-index:3; padding:18px 20px 24px; 18 + background:#0d1117; border-left:1px solid #21262d; overflow:auto; 19 + transform:translateX(100%); transition:transform .2s ease; } 20 + #panel.open { transform:none; } 21 + #panel .x { float:right; cursor:pointer; color:#8b949e; background:none; border:0; font-size:20px; line-height:1; } 22 + #panel h2 { margin:0 28px 2px 0; font-size:18px; word-break:break-word; } 23 + #panel .did { color:#8b949e; font:11px/1.4 ui-monospace,Menlo,monospace; word-break:break-all; margin-bottom:14px; } 24 + #panel .kv { display:flex; justify-content:space-between; align-items:center; padding:6px 0; border-bottom:1px solid #21262d; } 25 + #panel .kv b { font-variant-numeric:tabular-nums; } 26 + #panel h3 { font-size:12px; text-transform:uppercase; color:#656d76; margin:16px 0 4px; } 27 + #panel ul { padding-left:18px; margin:6px 0 0; color:#c9d1d9; } #panel li { margin:3px 0; } 28 + .pill { padding:2px 8px; border-radius:999px; color:#fff; font-size:11px; } 29 + .fast_lane{background:#1a7f37} .normal_queue{background:#9a6700} .needs_human{background:#cf222e} 30 + </style> 31 + </head> 32 + <body> 33 + <header> 34 + <a href="/" style="display:block;margin-bottom:8px"><img src="/static/logo.svg" alt="CyberCred" height="24"></a> 35 + <h1>Contributor graph</h1> 36 + <nav><a href="/">Triage</a><a href="/dashboard">Dashboard</a><a href="/leaderboard.html">Leaderboard</a><a href="/graph.html">Graph</a></nav> 37 + <span class="legend"> 38 + <span id="count"></span> 39 + <i style="background:#3fb950"></i>fast-lane 40 + <i style="background:#d29922"></i>normal 41 + <i style="background:#f85149"></i>needs human 42 + <i style="background:#3a4048"></i>unvouched 43 + <i style="background:#fff"></i>seed 44 + </span> 45 + </header> 46 + <div id="graph"></div> 47 + <aside id="panel"><button class="x">×</button><div class="body"></div></aside> 48 + <!-- ponytail: CDN force-graph (bundles d3-force) instead of hand-rolling a physics sim; 49 + vendor it locally if this dev surface ever needs to run offline. --> 50 + <script src="https://unpkg.com/force-graph"></script> 51 + <script> 52 + const COLOR = {fast_lane:'#3fb950', normal_queue:'#d29922', needs_human:'#f85149'}; 53 + const panel = document.getElementById('panel'); 54 + const esc = s => String(s ?? '').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); // handles are external data 55 + panel.querySelector('.x').onclick = () => panel.classList.remove('open'); 56 + 57 + function showNode(did) { 58 + panel.classList.add('open'); 59 + panel.querySelector('.body').textContent = 'loading…'; 60 + const j = url => fetch(url).then(r => r.json()).catch(() => ({})); // identity may 404/offline 61 + Promise.all([j(`/score/${encodeURIComponent(did)}`), j(`/identity/${encodeURIComponent(did)}`)]).then(([s, id]) => { 62 + const pct = v => v == null ? 'n/a' : (v * 100).toFixed(0) + '%'; 63 + const name = id.handle || s.handle; 64 + const factors = (s.top_factors || []).map(f => `<li>${esc(f)}</li>`).join('') || '<li>—</li>'; 65 + const pds = id.pds ? `<div class="kv"><span>PDS</span><b>${esc(id.pds.replace(/^https?:\/\//, ''))}</b></div>` : ''; 66 + panel.querySelector('.body').innerHTML = ` 67 + <h2>${esc(name || s.did)}</h2> 68 + <div class="did">${esc(s.did)}</div> 69 + <div class="kv"><span>decision</span><span class="pill ${s.decision}">${s.decision.replace(/_/g, ' ')}</span></div> 70 + <div class="kv"><span>calibrated trust</span><b>${pct(s.calibrated_prob)}</b></div> 71 + <div class="kv"><span>structural trust</span><b>${pct(s.structural_trust)}</b></div> 72 + <div class="kv"><span>content risk</span><b>${pct(s.content_risk)}</b></div> 73 + ${pds} 74 + <h3>Why</h3><ul>${factors}</ul>`; 75 + }).catch(() => { panel.querySelector('.body').textContent = 'failed to load'; }); 76 + } 77 + 78 + fetch('/graph').then(r => r.json()).then(data => { 79 + count.textContent = `${data.nodes.length.toLocaleString()} contributors · ${data.links.length.toLocaleString()} vouches`; 80 + const G = ForceGraph()(document.getElementById('graph')) 81 + .backgroundColor('#0d1117') 82 + .nodeId('id') 83 + .nodeLabel(n => `${n.handle} · trust ${(n.trust*100).toFixed(0)}%`) 84 + .linkColor(l => l.polarity < 0 ? 'rgba(248,81,73,.55)' : 'rgba(139,148,158,.22)') 85 + .linkDirectionalArrowLength(3).linkDirectionalArrowRelPos(1) 86 + .onNodeClick(n => showNode(n.id)) 87 + .onBackgroundClick(() => panel.classList.remove('open')) 88 + .nodeCanvasObject((n, ctx, scale) => { 89 + const r = (n.seed ? 5 : 2.5) + n.trust * 6; 90 + ctx.beginPath(); 91 + ctx.arc(n.x, n.y, r, 0, 2 * Math.PI); 92 + // Unvouched (trust 0) nodes are "unknown", not "rejected" — dim grey, not a sea of red. 93 + ctx.fillStyle = n.seed ? '#fff' : n.trust ? (COLOR[n.decision] || '#8b949e') : '#3a4048'; 94 + ctx.fill(); 95 + // Obsidian-style: label only the prominent nodes until you zoom in, else it's mush. 96 + if (scale > 1.4 || n.seed || n.trust > 0.6) { 97 + ctx.font = `${11 / scale}px system-ui, sans-serif`; 98 + ctx.fillStyle = '#c9d1d9'; 99 + ctx.textAlign = 'center'; 100 + ctx.fillText(n.handle, n.x, n.y + r + 10 / scale); 101 + } 102 + }) 103 + // Custom nodeCanvasObject means we must paint the click area too, or hit-testing fails. 104 + .nodePointerAreaPaint((n, color, ctx) => { 105 + ctx.fillStyle = color; 106 + ctx.beginPath(); 107 + ctx.arc(n.x, n.y, Math.max((n.seed ? 5 : 2.5) + n.trust * 6, 5), 0, 2 * Math.PI); 108 + ctx.fill(); 109 + }) 110 + .graphData(data); 111 + G.d3Force('charge').strength(-120); // spread the cluster out so edges are readable 112 + }); 113 + </script> 114 + </body> 115 + </html>
+2 -1
src/trust/static/leaderboard.html
··· 18 18 </head> 19 19 <body> 20 20 <header> 21 + <a href="/" style="display:block;margin-bottom:8px"><img src="/static/logo.svg" alt="CyberCred" height="24"></a> 21 22 <h1>Leaderboard</h1> 22 - <nav><a href="/">Triage</a><a href="/dashboard">Dashboard</a><a href="/leaderboard.html">Leaderboard</a></nav> 23 + <nav><a href="/">Triage</a><a href="/dashboard">Dashboard</a><a href="/leaderboard.html">Leaderboard</a><a href="/graph.html">Graph</a></nav> 23 24 </header> 24 25 <main> 25 26 <table><thead><tr><th class="rank">#</th><th>Contributor</th><th>Trust</th><th>Decision</th></tr></thead>
+12
src/trust/static/logo.svg
··· 1 + <svg width="2000" height="408" viewBox="0 0 2000 408" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M1999.7 31.284C1999.71 121.876 1999.5 211.196 1999.98 300.512C2000.05 313.12 1996.99 319.042 1983.6 317.178C1978.15 316.419 1972.51 317.029 1966.95 317.018C1944.96 316.974 1944.96 316.974 1939.6 294.304C1911.86 320.982 1879.3 327.309 1843.67 318.057C1821.18 312.218 1801.93 300.189 1787.71 281.632C1754.42 238.201 1755.27 172.114 1789.1 131.561C1823.58 90.2123 1874.52 83.8607 1934.12 113.601C1938.63 110.045 1936.62 104.971 1936.66 100.637C1936.87 72.433 1937.33 44.212 1936.57 16.0271C1936.23 3.40498 1940.92 -0.719591 1952.96 0.120308C1963.59 0.861895 1974.35 0.741178 1984.99 0.0774576C1995.92 -0.604316 2001.12 3.13021 1999.8 14.6385C1999.21 19.6991 1999.7 24.8831 1999.7 31.284ZM1837.11 171.247C1813.95 205.707 1829.82 251.269 1861.9 263.08C1888.29 272.796 1918.1 261.889 1931.05 237.31C1945.22 210.415 1937.26 173.972 1913.72 158.024C1888.51 140.941 1859.3 145.454 1837.11 171.247Z" fill="#8B7CF6"/> 3 + <path d="M542.804 0.242838C555.192 -0.980057 559.083 4.20306 558.942 15.1614C558.52 48.0262 558.79 80.8998 558.79 112.796C561.664 115.132 563.087 114.171 564.458 113.183C599.792 87.7148 637.212 88.148 674.667 106.551C712.908 125.34 729.64 158.74 731.678 200.058C733.936 245.817 718.323 283.113 677.754 307.382C640.921 329.416 596.65 326.433 562.497 300.235C560.541 298.735 558.467 297.387 556.565 296.049C555.441 297.102 554.547 297.545 554.311 298.219C547.725 316.984 547.736 316.988 527.082 317.007C496.3 317.035 496.328 317.035 496.328 285.634C496.328 196.281 496.29 106.928 496.3 17.5748C496.302 0.512556 496.372 0.484824 513.381 0.327718C522.785 0.240841 532.191 0.265205 542.804 0.242838ZM562.39 233.89C569.058 248.102 579.488 258.34 594.451 263.446C618.543 271.668 644.057 263.633 658.259 243.605C673.636 221.919 673.2 189.316 656.733 169.3C643.236 152.892 625.665 145.787 604.462 149.004C581.932 152.421 567.597 165.825 560.374 186.969C555.224 202.042 555.652 217.188 562.39 233.89Z" fill="#8B7CF6"/> 4 + <path d="M781.664 291.041C748.231 256.649 739.148 216.811 752.339 172.223C765.146 128.928 795.991 103.91 839.283 96.4216C920.228 82.4201 977.823 132.039 977.874 211.328C977.88 219.867 976.478 226.528 965.489 226.478C923.574 226.284 881.657 226.446 839.741 226.392C836.875 226.388 833.604 226.72 830.945 221.35C857.176 206.881 875.225 177.368 911.237 182.015C907.832 160.468 888.685 145.918 865.186 145.13C840.381 144.299 818.148 159.436 813.548 180.867C827.235 185.083 842.166 179.437 859.059 185.733C844.874 195.982 832.914 204.581 820.999 213.243C807.415 223.119 806.883 225.486 814.612 240.431C832 274.052 882.681 279.78 907.094 250.146C913.325 242.584 918.638 241.811 926.714 244.74C937.557 248.673 948.5 252.412 959.597 255.534C968.505 258.04 968.826 262.124 964.597 269.472C944.165 304.972 896.573 326.815 851.984 321.38C825.771 318.184 802.351 309.087 781.664 291.041Z" fill="#8B7CF6"/> 5 + <path d="M1607.2 152.598C1596.72 159.661 1588.09 167.095 1584.77 180.334C1599.28 185.057 1614.44 180.054 1628.5 183.523C1630.14 188.803 1626.03 189.905 1623.54 191.698C1611.74 200.182 1599.88 208.587 1587.88 216.788C1582.04 220.784 1580.79 225.249 1583.21 232.104C1590.48 252.71 1605.32 264.613 1626.02 268.374C1647.03 272.192 1666.5 267.269 1680.18 249.823C1685.89 242.543 1691.07 241.783 1698.66 244.447C1712.26 249.224 1725.94 253.761 1740.02 258.544C1738.2 271.554 1731.06 280.275 1723.24 287.716C1685.72 323.425 1641.21 330.497 1593.63 314.296C1547.18 298.48 1521.68 263.1 1519.02 215.014C1514.91 140.411 1571.27 95.422 1629.46 94.4828C1646.22 94.2122 1662.71 94.9085 1678.71 100.783C1733.92 121.054 1749.66 170.497 1750.21 212.929C1750.31 220.38 1748.4 226.487 1738.86 226.461C1696.53 226.342 1654.21 226.422 1611.88 226.347C1609.39 226.343 1606.69 226.176 1603.48 221.727C1629.08 205.817 1647.74 178.128 1683.22 181.717C1678.95 150.53 1642.35 136.835 1607.2 152.598Z" fill="#8B7CF6"/> 6 + <path d="M180.67 108.566C205.218 122.257 221.342 141.708 231.23 169.082C212.474 173.98 194.934 178.772 177.271 183.055C171.426 184.472 169.453 179.123 166.88 175.25C151.066 151.448 127.172 142.605 101.471 150.996C76.5456 159.133 62.2767 182.59 63.9183 212.73C65.3201 238.468 83.7786 260.539 108.354 265.862C131.473 270.869 155.068 260.882 167.171 239.031C172.084 230.161 177.067 229.041 185.592 231.83C196.546 235.413 207.658 238.658 218.897 241.185C230.927 243.89 231.114 249.565 226.115 259.389C204.715 301.44 159.162 325.878 108.77 321.641C53.2701 316.974 12.5344 281.416 2.14917 228.572C-11.4518 159.365 41.2205 95.1868 111.568 94.3608C135.52 94.0796 158.451 96.66 180.67 108.566Z" fill="#8B7CF6"/> 7 + <path d="M1297.04 100.937C1326.71 111.773 1347.62 130.692 1360.83 158.381C1364.63 166.326 1362.91 170.288 1354.56 172.125C1347.47 173.683 1340.15 174.773 1333.46 177.415C1316.6 184.073 1304.4 184.851 1292.56 165.763C1280.21 145.847 1249.75 142.842 1227.41 154.001C1205.79 164.799 1192.49 192.386 1197.15 216.739C1202.25 243.316 1219.26 261.662 1242.94 266.127C1266.55 270.577 1288.17 260.175 1302.85 236.153C1306.67 229.891 1310.73 229.434 1316.57 231.115C1328.89 234.659 1341.16 238.375 1353.57 241.582C1363.39 244.122 1363.97 249.282 1360.18 257.701C1340.89 300.509 1288.32 328.149 1238.71 321.181C1177.88 312.638 1139.33 275.453 1134.76 214.34C1130.18 153.201 1170.77 101.437 1237.82 94.8586C1257.33 92.9439 1277.18 93.4848 1297.04 100.937Z" fill="#8B7CF6"/> 8 + <path d="M348.739 268.01C369.926 214.667 390.9 162.301 411.329 109.725C414.833 100.709 419.793 97.0193 429.381 97.5982C445.781 98.5884 462.352 96.6234 478.114 98.7292C480.546 105.561 477.402 109.58 475.726 113.773C438.149 207.813 400.41 301.788 363.002 395.894C359.858 403.803 355.761 407.367 347.025 407.083C330.092 406.531 313.129 406.922 293.118 406.922C312.294 358.998 330.317 313.957 348.739 268.01Z" fill="#8B7CF6"/> 9 + <path d="M1380.49 300.194C1380.5 237.833 1380.44 176.695 1380.57 115.557C1380.61 98.2863 1381.31 97.837 1398.88 97.7308C1405.29 97.6921 1411.7 97.6983 1418.12 97.6913C1438.64 97.6688 1438.63 97.6704 1439.36 117.737C1439.44 119.838 1439.69 121.933 1440.11 127.099C1450.91 112.317 1462.73 102.416 1478.14 97.6891C1480.59 96.9374 1483.03 96.1227 1485.53 95.5471C1514.18 88.9337 1519.34 92.9864 1519.15 121.886C1518.96 151.006 1518.96 151.006 1491.24 152.663C1467.9 154.058 1449.21 171.997 1445.22 197.079C1440.35 227.684 1443.52 258.521 1442.89 289.246C1442.32 317.019 1442.73 317.029 1414.89 317.02C1408.9 317.018 1402.86 316.466 1396.94 317.104C1384.92 318.401 1379.02 313.952 1380.49 300.194Z" fill="#8B7CF6"/> 10 + <path d="M997.534 241.726C997.543 199.867 997.506 159.293 997.578 118.718C997.616 97.7314 997.704 97.7313 1019.2 97.6735C1021.76 97.6666 1024.33 97.5592 1026.88 97.6798C1034.95 98.0608 1043.7 95.4483 1050.94 99.1391C1059.55 103.531 1053 113.382 1056.01 120.162C1056.49 121.233 1057.49 122.074 1058.77 123.684C1065.73 117.854 1070.68 110.208 1078.49 105.681C1085.57 101.576 1092.85 98.1965 1100.75 96.1119C1128.92 88.672 1134.78 92.9907 1134.78 121.193C1134.78 150.407 1134.78 150.407 1106.74 152.768C1079.26 155.082 1060.81 178.942 1060.35 213.694C1059.96 243.16 1059.76 272.646 1060.47 302.098C1060.76 313.97 1056.82 318.14 1045.1 317.231C1034.06 316.375 1022.88 316.499 1011.81 317.134C1001.35 317.734 997.194 313.884 997.463 303.227C997.968 283.167 997.561 263.083 997.534 241.726Z" fill="#8B7CF6"/> 11 + <path d="M327.205 196.445C346.392 227.48 344.44 257.209 326.632 287.113C321.466 295.787 320.188 306.488 311.743 318.385C281.24 244.548 251.7 173.041 221.305 99.4664C243.979 96.4046 263.826 97.3932 283.594 98.4589C289.584 98.7818 290.634 105.289 292.507 109.94C303.995 138.451 315.382 167.004 327.205 196.445Z" fill="#8B7CF6"/> 12 + </svg>
+2 -1
src/trust/static/triage.html
··· 24 24 </head> 25 25 <body> 26 26 <header> 27 + <a href="/" style="display:block;margin-bottom:8px"><img src="/static/logo.svg" alt="CyberCred" height="24"></a> 27 28 <h1>Triage queue</h1> 28 - <nav><a href="/">Triage</a><a href="/dashboard">Dashboard</a><a href="/leaderboard.html">Leaderboard</a></nav> 29 + <nav><a href="/">Triage</a><a href="/dashboard">Dashboard</a><a href="/leaderboard.html">Leaderboard</a><a href="/graph.html">Graph</a></nav> 29 30 </header> 30 31 <main> 31 32 <div class="strip" id="strip"></div>
+74
src/trust/voice.py
··· 1 + """M7 voice briefing (PRD: an ElevenLabs voice briefing on the API). 2 + 3 + The explanation `summary` is already "suitable to read aloud" (6.6/6.9), so this 4 + just composes a short spoken script from an assessment and pipes it to ElevenLabs. 5 + No SDK — one POST with httpx. 6 + 7 + Env (only ELEVENLABS_API_KEY touches the network): 8 + ELEVENLABS_API_KEY your key; absent -> the API returns the brief text instead of audio 9 + ELEVENLABS_VOICE_ID voice (default: a public ElevenLabs voice) 10 + """ 11 + 12 + from __future__ import annotations 13 + 14 + import os 15 + 16 + URL = "https://api.elevenlabs.io/v1/text-to-speech/{voice}" 17 + DEFAULT_VOICE = os.environ.get("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM") # "Rachel" (public) 18 + DECISION_PHRASE = { 19 + "fast_lane": "safe to fast-lane", 20 + "normal_queue": "for the normal review queue", 21 + "needs_human": "routed to a human reviewer", 22 + } 23 + 24 + 25 + def brief_text(score: dict) -> str: 26 + """2-3 sentence spoken script from an assessment. Skips raw DIDs (unspeakable).""" 27 + who = score.get("handle") or score["did"] 28 + pct = round((score.get("calibrated_prob") or 0) * 100) 29 + decision = DECISION_PHRASE.get(score["decision"], score["decision"]) 30 + reason = score.get("explanation") or {} 31 + out = [f"{who}: {decision}. Calibrated trust {pct} percent."] 32 + if reason.get("compliance_block"): 33 + out.append(reason["compliance_block"]) 34 + else: 35 + factor = next((f for f in reason.get("top_factors", []) 36 + if "did:" not in f and "trust reaches" not in f), None) 37 + if factor: 38 + out.append(factor.rstrip(".") + ".") 39 + if reason.get("content_summary"): 40 + out.append("Claude notes: " + reason["content_summary"]) 41 + return " ".join(out) 42 + 43 + 44 + def synthesize(text: str) -> bytes | None: 45 + """ElevenLabs TTS -> mp3 bytes, or None when no API key is configured.""" 46 + key = os.environ.get("ELEVENLABS_API_KEY") 47 + if not key: 48 + return None 49 + import httpx 50 + 51 + r = httpx.post(URL.format(voice=DEFAULT_VOICE), 52 + headers={"xi-api-key": key, "accept": "audio/mpeg"}, 53 + json={"text": text, "model_id": "eleven_turbo_v2_5"}, timeout=60) 54 + r.raise_for_status() 55 + return r.content 56 + 57 + 58 + def demo() -> None: 59 + """Self-check: build a sensible script; synth is a no-op without a key.""" 60 + script = brief_text({ 61 + "did": "did:plc:alice", "handle": "alice.dev", "calibrated_prob": 1.0, 62 + "decision": "needs_human", 63 + "explanation": {"compliance_block": "sensitive-tier repo: a valid jurisdiction " 64 + "attestation is required before fast-lane/merge (6.13)", 65 + "top_factors": ["trust reaches did:plc:alice via maintainer", "8 merged PRs"]}, 66 + }) 67 + print(script) 68 + assert "alice.dev" in script and "human reviewer" in script and "did:" not in script 69 + assert synthesize(script) is None or isinstance(synthesize(script), bytes) 70 + print("ok") 71 + 72 + 73 + if __name__ == "__main__": 74 + demo()
+87
src/trust/vouchsafe.py
··· 1 + """Static secret/SAST scan of a PR diff -> advisory machine_findings (PRD 6.12). 2 + 3 + Regex patterns ported from VouchSafe (tangled.org/ivoine.tngl.sh/hackathon, MIT) -- 4 + the "OSV/secret-scan/SAST" external signal the README lists as skipped. Two deliberate 5 + adaptations for this system: 6 + 7 + - Scan only ADDED lines of the unified diff: a removed secret is not a leak, and 8 + context lines aren't the contribution under review. 9 + - REDACT every matched secret before returning. Findings ride into a Claude prompt 10 + and into the published sh.tangled.trust.score record (6.11), so the raw value must 11 + never echo back out -- a scanner that leaks the secret it found is worse than none. 12 + 13 + Advisory only, like the slop signal: it hints Claude (machine_findings) and adds a 14 + line to the explanation. It never flips the gate -- only the graph and attestation do 15 + (constraint 2). To make a critical leak force review, gate should_review on it; left 16 + out by intent so the gate contract stays in fusion.decide(). 17 + """ 18 + 19 + from __future__ import annotations 20 + 21 + import re 22 + 23 + # (name, pattern, severity). Per-line scan over added lines, so no re.S/multiline needed. 24 + _PATTERNS = [ 25 + ("Exposed API Key", re.compile(r"(?:api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*['\"]([a-zA-Z0-9_\-]{20,})['\"]?", re.I), "critical"), 26 + ("AWS Access Key", re.compile(r"(?:A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}"), "critical"), 27 + ("Private Key", re.compile(r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----"), "critical"), 28 + ("Hardcoded Password", re.compile(r"(?:password|passwd|pwd)\s*[=:]\s*['\"]([^'\"]{8,})['\"]?", re.I), "high"), 29 + ("SQL Injection Risk", re.compile(r"(?:execute|query|prepare)\s*\(\s*['\"]\s*SELECT.*\+.*['\"]|(?:execute|query)\s*\(\s*['\"].*\$\{.*\}.*['\"]", re.I), "high"), 30 + ("eval() Usage", re.compile(r"\beval\s*\("), "high"), 31 + ("JWT Token", re.compile(r"eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}"), "medium"), 32 + ("Generic Secret", re.compile(r"(?:secret|token|bearer)\s*[=:]\s*['\"]([a-zA-Z0-9_\-]{20,})['\"]?", re.I), "medium"), 33 + ] 34 + 35 + _HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)") 36 + 37 + 38 + def _redact(line: str, m: re.Match) -> str: 39 + """Mask the sensitive token (capture group if any, else the whole match) so a 40 + finding can travel into a public prompt/record without leaking the secret.""" 41 + secret = m.group(1) if m.lastindex else m.group(0) 42 + masked = secret[:4] + "...[redacted]" if len(secret) > 4 else "...[redacted]" 43 + return line.replace(secret, masked).strip()[:120] 44 + 45 + 46 + def scan_diff(diff: str | None) -> list[dict]: 47 + """Findings on ADDED lines of a unified diff: [{type, severity, line, snippet}]. 48 + `line` is the new-file line number; `snippet` has the secret redacted. Empty list 49 + if nothing matched or no diff (so callers can `or None` it into machine_findings).""" 50 + if not diff: 51 + return [] 52 + findings: list[dict] = [] 53 + new_line = 0 54 + for raw in diff.splitlines(): 55 + h = _HUNK.match(raw) 56 + if h: 57 + new_line = int(h.group(1)) 58 + continue 59 + if raw.startswith(("+++", "---")): # file headers, not content 60 + continue 61 + if raw.startswith("+"): 62 + text = raw[1:] 63 + for name, pat, sev in _PATTERNS: 64 + for m in pat.finditer(text): 65 + findings.append({"type": name, "severity": sev, 66 + "line": new_line, "snippet": _redact(text, m)}) 67 + new_line += 1 68 + elif not raw.startswith("-"): # context line advances the new-file counter 69 + new_line += 1 70 + return findings 71 + 72 + 73 + def demo() -> None: 74 + """Self-check: flags an added secret, redacts it, ignores removals.""" 75 + leak = ('@@ -1,2 +1,3 @@\n context\n+api_key = "AKIAIOSFODNN7EXAMPLE12"\n-old = 1\n') 76 + f = scan_diff(leak) 77 + assert f and f[0]["type"] == "Exposed API Key", f 78 + assert f[0]["line"] == 2, f # hunk +1, context line 1, added line 2 79 + assert "AKIAIOSFODNN7EXAMPLE12" not in f[0]["snippet"], "secret must be redacted" 80 + # a secret only REMOVED is not a leak 81 + assert scan_diff('@@ -1 +0,0 @@\n-password = "hunter2hunter2"\n') == [] 82 + assert scan_diff("") == [] 83 + print(f"scan_diff ok: {len(f)} finding(s); {f[0]['type']} @line {f[0]['line']} :: {f[0]['snippet']}") 84 + 85 + 86 + if __name__ == "__main__": 87 + demo()
+78
tests/test_smoke.py
··· 17 17 review.demo() # parses schema, or no-ops cleanly without an API key 18 18 19 19 20 + def test_static_scan_flags_added_secrets(): 21 + from trust import vouchsafe 22 + vouchsafe.demo() # flags an added secret, redacts the value, ignores removals 23 + 24 + 20 25 def test_learned_ranks_trusted_above_sybil(): 21 26 pytest.importorskip("lightgbm") # M5 is the .[learned] extra; skip if not installed 22 27 from trust import learned ··· 32 37 def test_atproto_record_shape(): 33 38 from trust import atproto 34 39 atproto.demo() # builds a valid sh.tangled.trust.score record (no network) 40 + 41 + 42 + def test_voice_brief_text(): 43 + from trust import voice 44 + voice.demo() # composes a speakable brief; synth no-ops without a key 45 + 46 + 47 + def test_embed_slop_ranking(): 48 + from trust import embed 49 + embed.demo() # cosine identities + slop-corpus ranking; live embed only with a key 50 + 51 + 52 + def test_diffs_gunzip_roundtrip(): 53 + from trust import diffs 54 + diffs.demo() # patchBlob gunzip round-trip + CID extraction (no network) 55 + 56 + 57 + def test_content_head_outranks_bad_diff(): 58 + pytest.importorskip("sklearn") # Tower B head is the .[learned] extra (scikit-learn) 59 + from trust import content 60 + content.demo() # frozen-embedding linear probe: a bad diff out-risks a clean one 61 + 62 + 63 + def test_pull_status_drives_clean_merge(): 64 + """sh.tangled.repo.pull.status (public) -> merged/closed -> clean_merge label.""" 65 + import json 66 + import duckdb 67 + from trust import db, ingest 68 + 69 + con = duckdb.connect(":memory:") 70 + con.execute(db.SCHEMA); con.execute(db.FEATURES_VIEW); con.execute(db.PR_LABELS_VIEW) 71 + did = "did:plc:x" 72 + old = "2020-01-01T00:00:00Z" # well past the N-day window, so not NULL'd 73 + def ev(coll, rkey, rec): return (did, 0, "create", coll, rkey, json.dumps(rec)) 74 + ingest.derive(con, [ 75 + ev("sh.tangled.repo.pull", "r1", {"createdAt": old, "target": {"repo": "x", "branch": "main"}}), 76 + ev("sh.tangled.repo.pull.status", "s1", 77 + {"pull": f"at://{did}/sh.tangled.repo.pull/r1", "status": "sh.tangled.repo.pull.status.merged"}), 78 + ev("sh.tangled.repo.pull", "r2", {"createdAt": old}), 79 + ev("sh.tangled.repo.pull.status", "s2", 80 + {"pull": f"at://{did}/sh.tangled.repo.pull/r2", "status": "sh.tangled.repo.pull.status.closed"}), 81 + ]) 82 + lbl = dict(con.execute("SELECT pr_id, clean_merge FROM pr_labels").fetchall()) 83 + assert lbl[f"{did}/sh.tangled.repo.pull/r1"] == 1, "merged + old + no revert -> clean_merge=1" 84 + assert lbl[f"{did}/sh.tangled.repo.pull/r2"] == 0, "closed unmerged -> clean_merge=0" 85 + 86 + 87 + def test_stars_trust_weights_by_starrer_trust(): 88 + """A star from a trusted DID must outweigh a star from an untrusted/sybil DID.""" 89 + import json 90 + import duckdb 91 + from trust import db, ingest, eigentrust 92 + 93 + con = duckdb.connect(":memory:") 94 + con.execute(db.SCHEMA); con.execute(db.FEATURES_VIEW); con.execute(db.PR_LABELS_VIEW) 95 + # trusted chain seed -> alice; sybil is isolated (no incoming trust). 96 + con.execute("INSERT INTO contributors (did) VALUES ('seed'),('alice'),('sybil')") 97 + con.execute("INSERT INTO vouches (voucher_did, subject_did) VALUES ('seed','alice')") 98 + con.execute("INSERT INTO seeds VALUES ('seed')") 99 + 100 + def star(starrer, owner): # derive() inserts the starrer as a contributor too 101 + ingest.derive(con, [(starrer, 0, "create", "sh.tangled.feed.star", f"{starrer}-{owner}", 102 + json.dumps({"subject": {"did": owner, "$type": "sh.tangled.feed.star#repo"}}))]) 103 + star("alice", "did:plc:ownerA") # endorsed by a trusted DID 104 + star("sybil", "did:plc:ownerB") # endorsed by an untrusted DID 105 + 106 + er = eigentrust.compute(con) 107 + a = er.stars_trust.get("did:plc:ownerA", 0.0) 108 + b = er.stars_trust.get("did:plc:ownerB", 0.0) 109 + assert a > b, f"trusted-starred owner ({a}) must outrank sybil-starred owner ({b})" 110 + # raw count is blind to starrer trust: both owners show 1 star received. 111 + raw = dict(con.execute("SELECT owner_did, COUNT(*) FROM stars GROUP BY owner_did").fetchall()) 112 + assert raw["did:plc:ownerA"] == raw["did:plc:ownerB"] == 1
+2
uv.lock
··· 733 733 { name = "anthropic" }, 734 734 { name = "duckdb" }, 735 735 { name = "fastapi" }, 736 + { name = "httpx" }, 736 737 { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, 737 738 { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, 738 739 { name = "pydantic" }, ··· 747 748 { name = "anthropic", specifier = ">=0.40" }, 748 749 { name = "duckdb", specifier = ">=1.1" }, 749 750 { name = "fastapi", specifier = ">=0.115" }, 751 + { name = "httpx", specifier = ">=0.27" }, 750 752 { name = "numpy", specifier = ">=1.26" }, 751 753 { name = "pydantic", specifier = ">=2.7" }, 752 754 { name = "scipy", specifier = ">=1.11" },
+7
web/.gitignore
··· 1 + node_modules/ 2 + /build 3 + /.svelte-kit 4 + /.vite 5 + .env 6 + .env.* 7 + !.env.example
+38
web/README.md
··· 1 + # sunstead-web 2 + 3 + SvelteKit 5 UI for the Tangled trust scorer. Thin client over the FastAPI JSON 4 + endpoints (`trust-api`, :8003) — it never touches DuckDB. Surfaces: 5 + 6 + - `/` — **Triage queue**: every open PR, searchable/sortable/filterable, each row expands to the full provenance (trust path, top factors, TreeSHAP model factors, Claude flags, content/slop risk, compliance block). 7 + - `/dashboard` — fast-lane & false-approval rates, score histogram, decision donut, and a force-directed **vouch graph** (`/graph`). 8 + - `/leaderboard` — contributors ranked by calibrated trust. 9 + - `/backfill` — live scrape progress (polls `/backfill/status`). 10 + 11 + ## Run 12 + 13 + ```sh 14 + bun install 15 + bun run dev # http://localhost:5173 ; assumes the scorer on :8003 16 + API_BASE=http://host:port bun run dev # point at a different scorer 17 + ``` 18 + 19 + `mprocs` brings up the whole stack (scorer + this UI) from the repo root. 20 + 21 + `bun run build` → `node build` for the adapter-node production server (set 22 + `API_BASE` in its env). 23 + 24 + ## Stack 25 + 26 + Svelte 5 runes · bits-ui · Lightning CSS (6-layer cascade in `app.css`) · 27 + unplugin-icons/lucide · svelte-sonner · zod (validates every API response) · 28 + better-result · adapter-node. 29 + 30 + `/api/*` is a same-origin proxy (`routes/api/[...path]`) to the scorer, so there's 31 + no CORS and dev/prod fetch the same way. 32 + 33 + Skipped from the house stack — not needed by a read-only dashboard, add when warranted: 34 + superforms/formsnap & sveltednd (no forms or DnD), paraglide (single locale), 35 + tanstack/table-core (`$derived` sort/filter/paginate covers thousands of rows; 36 + add table-core for column virtualization or faceting), layerchart (the published 37 + 1.x is Tailwind-coupled and renders axes invisible on dark — the two charts are 38 + hand-rolled SVG; revisit if charts need axes/tooltips/zoom).
+366
web/bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "sunstead-web", 7 + "dependencies": { 8 + "better-result": "^2.9.2", 9 + "bits-ui": "^2.18.1", 10 + "svelte-sonner": "^1.1.1", 11 + "zod": "^4.4.3", 12 + }, 13 + "devDependencies": { 14 + "@iconify-json/lucide": "^1.2.114", 15 + "@sveltejs/adapter-node": "^5.5.7", 16 + "@sveltejs/kit": "^2.68.0", 17 + "@sveltejs/vite-plugin-svelte": "^7.1.2", 18 + "lightningcss": "^1.32.0", 19 + "svelte": "^5.56.4", 20 + "svelte-check": "^4.7.1", 21 + "typescript": "^6.0.3", 22 + "unplugin-icons": "^23.0.1", 23 + "vite": "^8.1.0", 24 + }, 25 + }, 26 + }, 27 + "packages": { 28 + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], 29 + 30 + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], 31 + 32 + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], 33 + 34 + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], 35 + 36 + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], 37 + 38 + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], 39 + 40 + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], 41 + 42 + "@iconify-json/lucide": ["@iconify-json/lucide@1.2.114", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-NbvH3B1BYo6wBtS7joLi7f2UVQOqK2dtZodMFf3kkBs+Tnh9TkRuy8oVHr1RM8UK6bUtvAXxfNlGAah0CuvPCw=="], 43 + 44 + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], 45 + 46 + "@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="], 47 + 48 + "@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="], 49 + 50 + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], 51 + 52 + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], 53 + 54 + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 55 + 56 + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 57 + 58 + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], 59 + 60 + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], 61 + 62 + "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], 63 + 64 + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], 65 + 66 + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], 67 + 68 + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], 69 + 70 + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="], 71 + 72 + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="], 73 + 74 + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="], 75 + 76 + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="], 77 + 78 + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="], 79 + 80 + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="], 81 + 82 + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="], 83 + 84 + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="], 85 + 86 + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="], 87 + 88 + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="], 89 + 90 + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="], 91 + 92 + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="], 93 + 94 + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="], 95 + 96 + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], 97 + 98 + "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg=="], 99 + 100 + "@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="], 101 + 102 + "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="], 103 + 104 + "@rollup/plugin-replace": ["@rollup/plugin-replace@6.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA=="], 105 + 106 + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], 107 + 108 + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], 109 + 110 + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], 111 + 112 + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], 113 + 114 + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], 115 + 116 + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], 117 + 118 + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], 119 + 120 + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], 121 + 122 + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], 123 + 124 + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], 125 + 126 + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], 127 + 128 + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], 129 + 130 + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], 131 + 132 + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], 133 + 134 + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], 135 + 136 + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], 137 + 138 + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], 139 + 140 + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], 141 + 142 + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], 143 + 144 + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], 145 + 146 + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], 147 + 148 + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], 149 + 150 + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], 151 + 152 + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], 153 + 154 + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], 155 + 156 + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], 157 + 158 + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], 159 + 160 + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], 161 + 162 + "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.7", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-replace": "^6.0.3", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g=="], 163 + 164 + "@sveltejs/kit": ["@sveltejs/kit@2.68.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-PdKiWsqinAoubVsSiRgVFkg3MHzGhQPnwQ8VxnGQKpZYijpapZ3UHHBje0GeByt2TvfjHPw+kxV+dNK2RIZg9g=="], 165 + 166 + "@sveltejs/load-config": ["@sveltejs/load-config@0.2.0", "", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="], 167 + 168 + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.1.2", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA=="], 169 + 170 + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], 171 + 172 + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], 173 + 174 + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], 175 + 176 + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], 177 + 178 + "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], 179 + 180 + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], 181 + 182 + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], 183 + 184 + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], 185 + 186 + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], 187 + 188 + "better-result": ["better-result@2.9.2", "", {}, "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q=="], 189 + 190 + "bits-ui": ["bits-ui@2.18.1", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA=="], 191 + 192 + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], 193 + 194 + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 195 + 196 + "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], 197 + 198 + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], 199 + 200 + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], 201 + 202 + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 203 + 204 + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], 205 + 206 + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 207 + 208 + "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], 209 + 210 + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], 211 + 212 + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], 213 + 214 + "esrap": ["esrap@2.2.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw=="], 215 + 216 + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], 217 + 218 + "exsolve": ["exsolve@1.1.0", "", {}, "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw=="], 219 + 220 + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 221 + 222 + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 223 + 224 + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 225 + 226 + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], 227 + 228 + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], 229 + 230 + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], 231 + 232 + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], 233 + 234 + "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], 235 + 236 + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], 237 + 238 + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], 239 + 240 + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], 241 + 242 + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], 243 + 244 + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], 245 + 246 + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], 247 + 248 + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], 249 + 250 + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], 251 + 252 + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], 253 + 254 + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], 255 + 256 + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], 257 + 258 + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], 259 + 260 + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], 261 + 262 + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], 263 + 264 + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], 265 + 266 + "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], 267 + 268 + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], 269 + 270 + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], 271 + 272 + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], 273 + 274 + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], 275 + 276 + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], 277 + 278 + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], 279 + 280 + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], 281 + 282 + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], 283 + 284 + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], 285 + 286 + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 287 + 288 + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 289 + 290 + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 291 + 292 + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], 293 + 294 + "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], 295 + 296 + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], 297 + 298 + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], 299 + 300 + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], 301 + 302 + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], 303 + 304 + "rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], 305 + 306 + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], 307 + 308 + "runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="], 309 + 310 + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], 311 + 312 + "set-cookie-parser": ["set-cookie-parser@3.1.1", "", {}, "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA=="], 313 + 314 + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], 315 + 316 + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 317 + 318 + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], 319 + 320 + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 321 + 322 + "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="], 323 + 324 + "svelte-check": ["svelte-check@4.7.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg=="], 325 + 326 + "svelte-sonner": ["svelte-sonner@1.1.1", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-5cd3p7wa4cq0NsqslMwdlPb7x1JglEZ/GKrLePWNr5bCxR1nagAVrY01FRFrXfUGs41miLt3C327+8XJo5BzZw=="], 327 + 328 + "svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="], 329 + 330 + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], 331 + 332 + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], 333 + 334 + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], 335 + 336 + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], 337 + 338 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 339 + 340 + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], 341 + 342 + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], 343 + 344 + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], 345 + 346 + "unplugin-icons": ["unplugin-icons@23.0.1", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/utils": "^3.1.0", "local-pkg": "^1.1.2", "obug": "^2.1.1", "unplugin": "^2.3.11" }, "peerDependencies": { "@svgr/core": ">=7.0.0", "@svgx/core": "^1.0.1", "@vue/compiler-sfc": "^3.0.2", "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["@svgr/core", "@svgx/core", "@vue/compiler-sfc", "svelte"] }, "sha512-rv0XEJepajKzDLvRUWASM8K+8+/CCfZn2jtogXqg6RIp7kpatRc/aFrVJn8ANQA09e++lPEEv9yX8cC9enc+QQ=="], 347 + 348 + "vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="], 349 + 350 + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], 351 + 352 + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], 353 + 354 + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], 355 + 356 + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], 357 + 358 + "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], 359 + 360 + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], 361 + 362 + "svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="], 363 + 364 + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], 365 + } 366 + }
+30
web/package.json
··· 1 + { 2 + "name": "sunstead-web", 3 + "private": true, 4 + "version": "0.1.0", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite dev --port 5173", 8 + "build": "vite build", 9 + "preview": "node build", 10 + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" 11 + }, 12 + "devDependencies": { 13 + "@iconify-json/lucide": "^1.2.114", 14 + "@sveltejs/adapter-node": "^5.5.7", 15 + "@sveltejs/kit": "^2.68.0", 16 + "@sveltejs/vite-plugin-svelte": "^7.1.2", 17 + "lightningcss": "^1.32.0", 18 + "svelte": "^5.56.4", 19 + "svelte-check": "^4.7.1", 20 + "typescript": "^6.0.3", 21 + "unplugin-icons": "^23.0.1", 22 + "vite": "^8.1.0" 23 + }, 24 + "dependencies": { 25 + "better-result": "^2.9.2", 26 + "bits-ui": "^2.18.1", 27 + "svelte-sonner": "^1.1.1", 28 + "zod": "^4.4.3" 29 + } 30 + }
+158
web/src/app.css
··· 1 + /* Six-layer cascade discipline. Earlier layers lose to later ones, so source 2 + order inside a layer stops mattering across concerns. */ 3 + @layer reset, tokens, base, components, utilities, overrides; 4 + 5 + @layer reset { 6 + *, 7 + *::before, 8 + *::after { 9 + box-sizing: border-box; 10 + } 11 + * { 12 + margin: 0; 13 + } 14 + body { 15 + -webkit-font-smoothing: antialiased; 16 + } 17 + button, 18 + input, 19 + select { 20 + font: inherit; 21 + color: inherit; 22 + } 23 + a { 24 + color: inherit; 25 + text-decoration: none; 26 + } 27 + } 28 + 29 + @layer tokens { 30 + :root { 31 + color-scheme: dark; 32 + 33 + --bg: #0a0c10; 34 + --surface: #11151c; 35 + --surface-2: #161b24; 36 + --surface-3: #1d242f; 37 + --line: #232b36; 38 + --line-strong: #303a48; 39 + 40 + --fg: #e6edf3; 41 + --fg-muted: #9aa7b4; 42 + --fg-dim: #647084; 43 + 44 + --accent: #4c8dff; 45 + --accent-dim: #1f3354; 46 + 47 + --fast: #3fb950; 48 + --fast-dim: #11331b; 49 + --normal: #d8a226; 50 + --normal-dim: #3a2e0d; 51 + --danger: #f4604e; 52 + --danger-dim: #3d1714; 53 + 54 + --r-sm: 6px; 55 + --r: 10px; 56 + --r-lg: 14px; 57 + --pill: 999px; 58 + 59 + --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; 60 + --sans: system-ui, -apple-system, "Segoe UI", sans-serif; 61 + 62 + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.25); 63 + } 64 + } 65 + 66 + @layer base { 67 + body { 68 + background: var(--bg); 69 + color: var(--fg); 70 + font: 14px/1.55 var(--sans); 71 + min-height: 100vh; 72 + } 73 + h1, 74 + h2, 75 + h3 { 76 + font-weight: 650; 77 + line-height: 1.2; 78 + } 79 + ::selection { 80 + background: var(--accent-dim); 81 + } 82 + /* tabular numbers everywhere figures line up in tables/metrics */ 83 + table, 84 + .num { 85 + font-variant-numeric: tabular-nums; 86 + } 87 + ::-webkit-scrollbar { 88 + width: 10px; 89 + height: 10px; 90 + } 91 + ::-webkit-scrollbar-thumb { 92 + background: var(--line-strong); 93 + border-radius: var(--pill); 94 + border: 2px solid var(--bg); 95 + } 96 + } 97 + 98 + @layer components { 99 + .card { 100 + background: var(--surface); 101 + border: 1px solid var(--line); 102 + border-radius: var(--r-lg); 103 + box-shadow: var(--shadow); 104 + } 105 + .chip { 106 + display: inline-flex; 107 + align-items: center; 108 + gap: 5px; 109 + padding: 2px 9px; 110 + border-radius: var(--pill); 111 + background: var(--surface-3); 112 + border: 1px solid var(--line); 113 + color: var(--fg-muted); 114 + font-size: 12px; 115 + white-space: nowrap; 116 + } 117 + .mono { 118 + font-family: var(--mono); 119 + } 120 + .muted { 121 + color: var(--fg-muted); 122 + } 123 + .dim { 124 + color: var(--fg-dim); 125 + } 126 + } 127 + 128 + @layer utilities { 129 + .row { 130 + display: flex; 131 + align-items: center; 132 + gap: 10px; 133 + } 134 + .wrap { 135 + flex-wrap: wrap; 136 + } 137 + .grow { 138 + flex: 1; 139 + } 140 + .stack { 141 + display: flex; 142 + flex-direction: column; 143 + gap: 6px; 144 + } 145 + .truncate { 146 + overflow: hidden; 147 + text-overflow: ellipsis; 148 + white-space: nowrap; 149 + } 150 + } 151 + 152 + @layer overrides { 153 + /* Reserved for one-off escapes from component styling. Empty by design the 154 + six layers above carry the weight; overrides should stay near-empty. */ 155 + [hidden] { 156 + display: none !important; 157 + } 158 + }
+7
web/src/app.d.ts
··· 1 + /// <reference types="unplugin-icons/types/svelte" /> 2 + 3 + declare global { 4 + namespace App {} 5 + } 6 + 7 + export {};
+12
web/src/app.html
··· 1 + <!doctype html> 2 + <html lang="en" data-theme="dark"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <link rel="icon" href="data:," /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 + %sveltekit.head% 8 + </head> 9 + <body data-sveltekit-preload-data="hover"> 10 + <div style="display: contents">%sveltekit.body%</div> 11 + </body> 12 + </html>
+134
web/src/lib/api.ts
··· 1 + import { Result } from 'better-result'; 2 + import { z } from 'zod'; 3 + 4 + // Schemas mirror src/trust/api.py + fusion.build_reason. Validating here is the 5 + // trust boundary: the scoring service is the brain, this UI never reads the DB, 6 + // so a shape drift surfaces as a clean 502 instead of a blank render. 7 + 8 + export const Decision = z.enum(['fast_lane', 'normal_queue', 'needs_human']); 9 + export type Decision = z.infer<typeof Decision>; 10 + 11 + const Flag = z.object({ 12 + severity: z.string(), 13 + type: z.string(), 14 + location: z.string(), 15 + explanation: z.string(), 16 + }); 17 + export type Flag = z.infer<typeof Flag>; 18 + 19 + const ModelFactor = z.object({ feature: z.string(), contribution: z.number() }); 20 + 21 + export const Profile = z.object({ 22 + name: z.string().nullable().default(null), // preferredHandle from sh.tangled.actor.profile 23 + avatar_cid: z.string().nullable().default(null), // blob CID -> bsky CDN thumbnail 24 + links: z.array(z.string()).default([]), 25 + location: z.string().nullable().default(null), 26 + pronouns: z.string().nullable().default(null), 27 + description: z.string().nullable().default(null), 28 + }); 29 + export type Profile = z.infer<typeof Profile>; 30 + 31 + export const Explanation = z.object({ 32 + structural_trust: z.number().default(0), 33 + trust_path: z.array(z.string()).default([]), 34 + top_factors: z.array(z.string()).default([]), 35 + model_factors: z.array(ModelFactor).default([]), 36 + compliance_block: z.string().nullable().default(null), 37 + // present only when Claude content review ran (cost-gated): 38 + content_summary: z.string().optional(), 39 + flags: z.array(Flag).optional(), 40 + content_risk: z.number().optional(), 41 + // Tower B / slop advisory signals: 42 + content_head_risk: z.number().optional(), 43 + slop_similarity: z.number().optional(), 44 + }); 45 + export type Explanation = z.infer<typeof Explanation>; 46 + 47 + export const TriageRow = z.object({ 48 + pr_id: z.string(), 49 + repo: z.string().nullable(), // backfilled PRs have no target repo 50 + handle: z.string().nullable(), 51 + did: z.string(), 52 + calibrated_prob: z.number(), 53 + decision: Decision, 54 + explanation: Explanation, 55 + profile: Profile.nullable().optional(), 56 + }); 57 + export type TriageRow = z.infer<typeof TriageRow>; 58 + export const TriageList = z.array(TriageRow); 59 + 60 + export const Metrics = z.object({ 61 + score_distribution: z.array(z.number()), 62 + decisions: z.object({ 63 + fast_lane: z.number(), 64 + normal_queue: z.number(), 65 + needs_human: z.number(), 66 + }), 67 + fast_lane_rate: z.number(), 68 + false_approval_rate: z.number().nullable(), 69 + vouch_graph: z.object({ 70 + contributors: z.number(), 71 + edges: z.number(), 72 + seeds: z.number(), 73 + }), 74 + ingest_last_time_us: z.number().nullable(), 75 + }); 76 + export type Metrics = z.infer<typeof Metrics>; 77 + 78 + export const LeaderRow = z.object({ 79 + did: z.string(), 80 + handle: z.string().nullable(), 81 + calibrated_prob: z.number(), 82 + decision: Decision, 83 + profile: Profile.nullable().optional(), 84 + vouches: z.number().default(0), // positive vouches received 85 + }); 86 + export const LeaderList = z.array(LeaderRow); 87 + export type LeaderRow = z.infer<typeof LeaderRow>; 88 + 89 + export const BackfillStatus = z.object({ 90 + collections: z.record(z.string(), z.number()), 91 + total: z.number(), 92 + derived: z.object({ 93 + contributors: z.number(), 94 + vouches: z.number(), 95 + pull_requests: z.number(), 96 + }), 97 + }); 98 + export type BackfillStatus = z.infer<typeof BackfillStatus>; 99 + 100 + export const GraphNode = z.object({ 101 + id: z.string(), 102 + handle: z.string(), 103 + trust: z.number(), 104 + decision: Decision, 105 + seed: z.boolean(), 106 + }); 107 + export type GraphNode = z.infer<typeof GraphNode>; 108 + 109 + export const GraphData = z.object({ 110 + nodes: z.array(GraphNode), 111 + links: z.array( 112 + z.object({ source: z.string(), target: z.string(), polarity: z.number() }), 113 + ), 114 + }); 115 + export type GraphData = z.infer<typeof GraphData>; 116 + 117 + /** GET `/api{path}` and validate. Returns a Result so callers decide how to fail 118 + * (load() throws a 502, the live poller toasts and keeps the last good data). */ 119 + export async function getJson<T>( 120 + fetchFn: typeof fetch, 121 + path: string, 122 + schema: z.ZodType<T>, 123 + ): Promise<Result<T, string>> { 124 + try { 125 + const res = await fetchFn(`/api${path}`); 126 + if (!res.ok) return Result.err(`scoring API returned ${res.status} for ${path}`); 127 + const parsed = schema.safeParse(await res.json()); 128 + return parsed.success 129 + ? Result.ok(parsed.data) 130 + : Result.err(`unexpected shape from ${path}: ${parsed.error.issues[0]?.message ?? 'invalid'}`); 131 + } catch (e) { 132 + return Result.err(`cannot reach scoring API: ${e instanceof Error ? e.message : String(e)}`); 133 + } 134 + }
+61
web/src/lib/components/Avatar.svelte
··· 1 + <script lang="ts"> 2 + // ponytail: avatar URL built client-side from did + blob CID via the bsky CDN 3 + // thumbnail (2-6 KB, cached). Falls back to a deterministic initials chip for the 4 + // ~non-mirrored ones. Add a PDS getBlob proxy only if you need the non-bsky avatars. 5 + let { 6 + did, 7 + cid, 8 + name = null, 9 + size = 30, 10 + }: { did: string; cid: string | null | undefined; name?: string | null; size?: number } = $props(); 11 + 12 + let failed = $state(false); 13 + const src = $derived( 14 + cid ? `https://cdn.bsky.app/img/avatar_thumbnail/plain/${did}/${cid}@jpeg` : null, 15 + ); 16 + const hue = $derived([...did].reduce((a, c) => a + c.charCodeAt(0), 0) % 360); 17 + const initial = $derived(((name ?? did.replace('did:plc:', '')).trim()[0] ?? '?').toUpperCase()); 18 + $effect(() => { 19 + cid; // reset the error state if the CID changes 20 + failed = false; 21 + }); 22 + </script> 23 + 24 + {#if src && !failed} 25 + <img 26 + class="av" 27 + {src} 28 + alt={name ?? 'avatar'} 29 + loading="lazy" 30 + decoding="async" 31 + style="width:{size}px;height:{size}px" 32 + onerror={() => (failed = true)} 33 + /> 34 + {:else} 35 + <span 36 + class="av ph" 37 + style="width:{size}px;height:{size}px;background:hsl({hue} 50% 32%);font-size:{Math.round( 38 + size * 0.42, 39 + )}px" 40 + aria-hidden="true">{initial}</span 41 + > 42 + {/if} 43 + 44 + <style> 45 + .av { 46 + border-radius: 50%; 47 + flex-shrink: 0; 48 + object-fit: cover; 49 + } 50 + img.av { 51 + border: 1px solid var(--line); 52 + background: var(--surface-3); 53 + } 54 + .ph { 55 + display: inline-grid; 56 + place-items: center; 57 + color: #fff; 58 + font-weight: 600; 59 + line-height: 1; 60 + } 61 + </style>
+64
web/src/lib/components/BarHistogram.svelte
··· 1 + <script lang="ts"> 2 + // ponytail: CSS flex bars, not a chart lib. A 10-bin histogram is a few lines 3 + // and themes with our tokens. Reach for layerchart if this needs axes/zoom/tooltips. 4 + let { 5 + bins, 6 + labels, 7 + color = 'var(--accent)', 8 + }: { bins: number[]; labels: string[]; color?: string } = $props(); 9 + const max = $derived(Math.max(1, ...bins)); 10 + </script> 11 + 12 + <div class="hist" role="img" aria-label="Trust score distribution"> 13 + {#each bins as c, i (i)} 14 + <div class="col" title="{labels[i]}: {c}"> 15 + <span class="cnt num">{c || ''}</span> 16 + <div class="track"> 17 + <div class="bar" style="height:{(c / max) * 100}%;background:{color}"></div> 18 + </div> 19 + <span class="lbl dim mono">{labels[i]}</span> 20 + </div> 21 + {/each} 22 + </div> 23 + 24 + <style> 25 + .hist { 26 + display: flex; 27 + align-items: stretch; 28 + gap: 6px; 29 + height: 160px; 30 + } 31 + .col { 32 + flex: 1; 33 + display: flex; 34 + flex-direction: column; 35 + align-items: center; 36 + gap: 4px; 37 + } 38 + .track { 39 + flex: 1; 40 + width: 100%; 41 + display: flex; 42 + align-items: flex-end; 43 + border-bottom: 1px solid var(--line); 44 + } 45 + .bar { 46 + width: 100%; 47 + border-radius: 4px 4px 0 0; 48 + min-height: 2px; 49 + transition: height 0.4s ease; 50 + opacity: 0.9; 51 + } 52 + .col:hover .bar { 53 + opacity: 1; 54 + box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 50%, transparent); 55 + } 56 + .cnt { 57 + font-size: 11px; 58 + color: var(--fg-muted); 59 + height: 14px; 60 + } 61 + .lbl { 62 + font-size: 11px; 63 + } 64 + </style>
+46
web/src/lib/components/DecisionPill.svelte
··· 1 + <script lang="ts"> 2 + import type { Decision } from '$lib/api'; 3 + import { DECISION_META } from '$lib/format'; 4 + 5 + let { decision, label = false }: { decision: Decision; label?: boolean } = $props(); 6 + const meta = $derived(DECISION_META[decision]); 7 + </script> 8 + 9 + <span class="pill {decision}" title={meta.label}> 10 + <span class="dot"></span>{label ? meta.label : meta.short} 11 + </span> 12 + 13 + <style> 14 + .pill { 15 + display: inline-flex; 16 + align-items: center; 17 + gap: 6px; 18 + padding: 2px 9px 2px 7px; 19 + border-radius: var(--pill); 20 + font-size: 11.5px; 21 + font-weight: 600; 22 + letter-spacing: 0.01em; 23 + border: 1px solid transparent; 24 + } 25 + .dot { 26 + width: 6px; 27 + height: 6px; 28 + border-radius: 50%; 29 + background: currentColor; 30 + } 31 + .fast_lane { 32 + color: var(--fast); 33 + background: var(--fast-dim); 34 + border-color: color-mix(in srgb, var(--fast) 30%, transparent); 35 + } 36 + .normal_queue { 37 + color: var(--normal); 38 + background: var(--normal-dim); 39 + border-color: color-mix(in srgb, var(--normal) 30%, transparent); 40 + } 41 + .needs_human { 42 + color: var(--danger); 43 + background: var(--danger-dim); 44 + border-color: color-mix(in srgb, var(--danger) 30%, transparent); 45 + } 46 + </style>
+103
web/src/lib/components/Donut.svelte
··· 1 + <script lang="ts"> 2 + // ponytail: one SVG, stroke-dasharray per segment. A 3-slice ring doesn't need a lib. 3 + let { 4 + segments, 5 + centerLabel = '', 6 + }: { segments: { label: string; value: number; color: string }[]; centerLabel?: string } = $props(); 7 + 8 + const R = 56; 9 + const C = 2 * Math.PI * R; 10 + const total = $derived(Math.max(1, segments.reduce((a, s) => a + s.value, 0))); 11 + const sumRaw = $derived(segments.reduce((a, s) => a + s.value, 0)); 12 + 13 + const arcs = $derived.by(() => { 14 + let acc = 0; 15 + return segments.map((s) => { 16 + const frac = s.value / total; 17 + const arc = { ...s, dash: frac * C, gap: C - frac * C, offset: -acc * C }; 18 + acc += frac; 19 + return arc; 20 + }); 21 + }); 22 + </script> 23 + 24 + <div class="donut"> 25 + <svg viewBox="0 0 150 150" width="150" height="150"> 26 + <circle cx="75" cy="75" r={R} fill="none" stroke="var(--surface-3)" stroke-width="16" /> 27 + <g transform="rotate(-90 75 75)"> 28 + {#each arcs as a (a.label)} 29 + <circle 30 + cx="75" 31 + cy="75" 32 + r={R} 33 + fill="none" 34 + stroke={a.color} 35 + stroke-width="16" 36 + stroke-dasharray="{a.dash} {a.gap}" 37 + stroke-dashoffset={a.offset} 38 + /> 39 + {/each} 40 + </g> 41 + <text x="75" y="72" text-anchor="middle" class="big">{sumRaw.toLocaleString()}</text> 42 + <text x="75" y="90" text-anchor="middle" class="cap">{centerLabel}</text> 43 + </svg> 44 + <div class="legend"> 45 + {#each segments as s (s.label)} 46 + <div class="leg"> 47 + <span class="sw" style="background:{s.color}"></span> 48 + <span class="grow">{s.label}</span> 49 + <b class="num">{s.value.toLocaleString()}</b> 50 + <span class="dim num">{((s.value / total) * 100).toFixed(0)}%</span> 51 + </div> 52 + {/each} 53 + </div> 54 + </div> 55 + 56 + <style> 57 + .donut { 58 + display: flex; 59 + align-items: center; 60 + gap: 22px; 61 + flex-wrap: wrap; 62 + } 63 + svg circle { 64 + transition: stroke-dasharray 0.5s ease; 65 + } 66 + .big { 67 + fill: var(--fg); 68 + font-size: 26px; 69 + font-weight: 700; 70 + } 71 + .cap { 72 + fill: var(--fg-dim); 73 + font-size: 9px; 74 + text-transform: uppercase; 75 + letter-spacing: 0.05em; 76 + } 77 + .legend { 78 + flex: 1; 79 + min-width: 160px; 80 + display: flex; 81 + flex-direction: column; 82 + gap: 8px; 83 + } 84 + .leg { 85 + display: flex; 86 + align-items: center; 87 + gap: 9px; 88 + font-size: 13px; 89 + } 90 + .sw { 91 + width: 11px; 92 + height: 11px; 93 + border-radius: 3px; 94 + flex-shrink: 0; 95 + } 96 + .leg b { 97 + font-variant-numeric: tabular-nums; 98 + } 99 + .leg .dim { 100 + width: 38px; 101 + text-align: right; 102 + } 103 + </style>
+78
web/src/lib/components/Nav.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + import IconActivity from '~icons/lucide/activity'; 4 + import IconLayout from '~icons/lucide/layout-dashboard'; 5 + import IconTrophy from '~icons/lucide/trophy'; 6 + import IconRadio from '~icons/lucide/radio'; 7 + 8 + const links = [ 9 + { href: '/', label: 'Triage', icon: IconActivity }, 10 + { href: '/dashboard', label: 'Dashboard', icon: IconLayout }, 11 + { href: '/leaderboard', label: 'Leaderboard', icon: IconTrophy }, 12 + { href: '/backfill', label: 'Backfill', icon: IconRadio }, 13 + ]; 14 + const path = $derived(page.url.pathname); 15 + </script> 16 + 17 + <header> 18 + <a class="brand" href="/"> 19 + <img class="logo" src="/logo.svg" alt="CyberCred" /> 20 + <span class="dim" style="font-size:11.5px">contributor scoring</span> 21 + </a> 22 + <nav> 23 + {#each links as l (l.href)} 24 + <a href={l.href} class:active={l.href === '/' ? path === '/' : path.startsWith(l.href)}> 25 + <l.icon /><span>{l.label}</span> 26 + </a> 27 + {/each} 28 + </nav> 29 + </header> 30 + 31 + <style> 32 + header { 33 + display: flex; 34 + align-items: center; 35 + gap: 28px; 36 + padding: 12px 24px; 37 + background: color-mix(in srgb, var(--surface) 80%, transparent); 38 + backdrop-filter: blur(8px); 39 + border-bottom: 1px solid var(--line); 40 + position: sticky; 41 + top: 0; 42 + z-index: 20; 43 + } 44 + .brand { 45 + display: flex; 46 + align-items: center; 47 + gap: 11px; 48 + text-decoration: none; 49 + } 50 + .logo { 51 + height: 22px; 52 + width: auto; 53 + display: block; 54 + } 55 + nav { 56 + display: flex; 57 + gap: 4px; 58 + } 59 + nav a { 60 + display: inline-flex; 61 + align-items: center; 62 + gap: 7px; 63 + padding: 7px 13px; 64 + border-radius: var(--r-sm); 65 + color: var(--fg-muted); 66 + font-weight: 550; 67 + border: 1px solid transparent; 68 + } 69 + nav a:hover { 70 + color: var(--fg); 71 + background: var(--surface-2); 72 + } 73 + nav a.active { 74 + color: var(--fg); 75 + background: var(--surface-3); 76 + border-color: var(--line-strong); 77 + } 78 + </style>
+327
web/src/lib/components/PrDetail.svelte
··· 1 + <script lang="ts"> 2 + import type { TriageRow } from '$lib/api'; 3 + import { pct, shortDid, host, ensureHttp, tangledProfile } from '$lib/format'; 4 + import { handleFor } from '$lib/identity'; 5 + import Avatar from './Avatar.svelte'; 6 + import IconArrow from '~icons/lucide/chevron-right'; 7 + import IconSeed from '~icons/lucide/sprout'; 8 + import IconLock from '~icons/lucide/lock'; 9 + import IconPin from '~icons/lucide/map-pin'; 10 + import IconLink from '~icons/lucide/external-link'; 11 + 12 + let { row }: { row: TriageRow } = $props(); 13 + const e = $derived(row.explanation); 14 + const p = $derived(row.profile); 15 + const handle = $derived(handleFor(row.did)); 16 + const name = $derived(p?.name ?? handle ?? row.handle ?? shortDid(row.did)); 17 + 18 + // The advisory scalars worth surfacing as labelled metrics (skip the undefined ones). 19 + const metrics = $derived( 20 + [ 21 + { k: 'Structural trust', v: e.structural_trust, fmt: pct }, 22 + { k: 'Content risk', v: e.content_risk, fmt: pct, warn: true }, 23 + { k: 'Content-head risk', v: e.content_head_risk, fmt: pct, warn: true }, 24 + { k: 'Slop similarity', v: e.slop_similarity, fmt: pct, warn: true }, 25 + ].filter((m) => m.v != null), 26 + ); 27 + </script> 28 + 29 + <div class="detail"> 30 + <header class="who"> 31 + <Avatar did={row.did} cid={p?.avatar_cid} {name} size={44} /> 32 + <div class="ident"> 33 + <div class="row" style="gap:8px"> 34 + <strong class="nm">{name}</strong> 35 + {#if handle}<a class="ext" href={tangledProfile(handle)} target="_blank" rel="noreferrer noopener" 36 + >@{handle}<IconLink style="width:11px;height:11px" /></a 37 + >{/if} 38 + {#if p?.pronouns}<span class="chip">{p.pronouns}</span>{/if} 39 + {#if p?.location}<span class="dim loc"><IconPin style="width:12px;height:12px" />{p.location}</span>{/if} 40 + </div> 41 + <span class="did mono dim" title={row.did}>{row.did}</span> 42 + {#if p?.description}<p class="bio">{p.description}</p>{/if} 43 + {#if p?.links?.length} 44 + <div class="links"> 45 + {#each p.links as l (l)} 46 + <a class="ext" href={ensureHttp(l)} target="_blank" rel="noreferrer noopener" 47 + ><IconLink style="width:11px;height:11px" />{host(l)}</a 48 + > 49 + {/each} 50 + </div> 51 + {/if} 52 + </div> 53 + </header> 54 + 55 + {#if e.compliance_block} 56 + <div class="block"> 57 + <IconLock style="width:14px;height:14px" /> 58 + {e.compliance_block} 59 + </div> 60 + {/if} 61 + 62 + <div class="grid"> 63 + <!-- trust provenance --> 64 + <section> 65 + <h4>Trust path</h4> 66 + {#if e.trust_path.length} 67 + <div class="path"> 68 + <span class="seed"><IconSeed style="width:13px;height:13px" /> seed</span> 69 + {#each e.trust_path as hop, i (hop + i)} 70 + <IconArrow class="sep" /> 71 + <span class="hop mono" class:last={i === e.trust_path.length - 1} title={hop} 72 + >{shortDid(hop)}</span 73 + > 74 + {/each} 75 + </div> 76 + {:else} 77 + <p class="dim">No path from a seed maintainer — trust can't propagate to this author.</p> 78 + {/if} 79 + 80 + {#if e.model_factors.length} 81 + <h4 style="margin-top:14px">Model factors (TreeSHAP)</h4> 82 + <div class="factors"> 83 + {#each e.model_factors as mf (mf.feature)} 84 + <div class="factor"> 85 + <span class="mono">{mf.feature}</span> 86 + <span class="contrib" class:pos={mf.contribution >= 0}> 87 + {mf.contribution >= 0 ? '+' : ''}{mf.contribution.toFixed(3)} 88 + </span> 89 + </div> 90 + {/each} 91 + </div> 92 + {/if} 93 + </section> 94 + 95 + <!-- factors + content review --> 96 + <section> 97 + <h4>Why this score</h4> 98 + {#if e.top_factors.length} 99 + <ul class="bullets"> 100 + {#each e.top_factors as f, i (i)}<li>{f}</li>{/each} 101 + </ul> 102 + {:else} 103 + <p class="dim">No contributing factors recorded.</p> 104 + {/if} 105 + 106 + <h4 style="margin-top:14px">Content review</h4> 107 + {#if e.content_summary} 108 + <p class="summary">{e.content_summary}</p> 109 + {:else} 110 + <p class="dim">No Claude review ran — cost-gated (clearly trusted or low-stakes).</p> 111 + {/if} 112 + {#if e.flags?.length} 113 + <div class="flags"> 114 + {#each e.flags as fl, i (i)} 115 + <div class="flag {fl.severity}"> 116 + <strong>[{fl.severity}] {fl.type}</strong> 117 + <span class="dim mono">@ {fl.location}</span> 118 + <span>{fl.explanation}</span> 119 + </div> 120 + {/each} 121 + </div> 122 + {/if} 123 + </section> 124 + </div> 125 + 126 + {#if metrics.length} 127 + <div class="metrics"> 128 + {#each metrics as m (m.k)} 129 + <div class="metric"> 130 + <span class="dim">{m.k}</span> 131 + <b class="num" class:warn={m.warn && (m.v ?? 0) > 0.2}>{m.fmt(m.v ?? 0)}</b> 132 + </div> 133 + {/each} 134 + </div> 135 + {/if} 136 + </div> 137 + 138 + <style> 139 + .detail { 140 + padding: 14px 16px 16px; 141 + background: var(--surface); 142 + border-top: 1px dashed var(--line-strong); 143 + display: flex; 144 + flex-direction: column; 145 + gap: 14px; 146 + } 147 + .who { 148 + display: flex; 149 + gap: 12px; 150 + align-items: flex-start; 151 + } 152 + .ident { 153 + display: flex; 154 + flex-direction: column; 155 + gap: 4px; 156 + min-width: 0; 157 + } 158 + .nm { 159 + font-size: 15px; 160 + } 161 + .loc { 162 + display: inline-flex; 163 + align-items: center; 164 + gap: 3px; 165 + font-size: 12px; 166 + } 167 + .bio { 168 + font-size: 13px; 169 + color: var(--fg-muted); 170 + max-width: 60ch; 171 + } 172 + .links { 173 + display: flex; 174 + flex-wrap: wrap; 175 + gap: 7px; 176 + margin-top: 2px; 177 + } 178 + .ext { 179 + display: inline-flex; 180 + align-items: center; 181 + gap: 4px; 182 + padding: 2px 9px; 183 + border-radius: var(--pill); 184 + background: var(--surface-3); 185 + border: 1px solid var(--line); 186 + color: var(--accent); 187 + font-size: 12px; 188 + } 189 + .ext:hover { 190 + border-color: var(--accent); 191 + } 192 + h4 { 193 + font-size: 11px; 194 + text-transform: uppercase; 195 + letter-spacing: 0.05em; 196 + color: var(--fg-dim); 197 + margin-bottom: 7px; 198 + } 199 + .grid { 200 + display: grid; 201 + grid-template-columns: 1fr 1fr; 202 + gap: 22px; 203 + } 204 + @media (max-width: 760px) { 205 + .grid { 206 + grid-template-columns: 1fr; 207 + } 208 + } 209 + .block { 210 + display: flex; 211 + align-items: center; 212 + gap: 7px; 213 + padding: 8px 11px; 214 + border-radius: var(--r-sm); 215 + background: var(--normal-dim); 216 + border: 1px solid color-mix(in srgb, var(--normal) 35%, transparent); 217 + color: var(--normal); 218 + font-size: 12.5px; 219 + } 220 + .path { 221 + display: flex; 222 + align-items: center; 223 + flex-wrap: wrap; 224 + gap: 5px; 225 + font-size: 12px; 226 + } 227 + .path :global(.sep) { 228 + width: 13px; 229 + height: 13px; 230 + color: var(--fg-dim); 231 + } 232 + .seed { 233 + display: inline-flex; 234 + align-items: center; 235 + gap: 4px; 236 + color: var(--fast); 237 + } 238 + .hop { 239 + padding: 2px 7px; 240 + border-radius: var(--r-sm); 241 + background: var(--surface-3); 242 + border: 1px solid var(--line); 243 + } 244 + .hop.last { 245 + border-color: var(--accent); 246 + color: var(--fg); 247 + } 248 + .factors, 249 + .flags { 250 + display: flex; 251 + flex-direction: column; 252 + gap: 5px; 253 + } 254 + .factor { 255 + display: flex; 256 + justify-content: space-between; 257 + font-size: 12px; 258 + padding: 3px 8px; 259 + background: var(--surface-2); 260 + border-radius: var(--r-sm); 261 + } 262 + .contrib { 263 + color: var(--danger); 264 + font-variant-numeric: tabular-nums; 265 + } 266 + .contrib.pos { 267 + color: var(--fast); 268 + } 269 + .bullets { 270 + margin: 0; 271 + padding-left: 18px; 272 + display: flex; 273 + flex-direction: column; 274 + gap: 3px; 275 + font-size: 13px; 276 + } 277 + .bullets li { 278 + overflow-wrap: anywhere; 279 + } 280 + .summary { 281 + font-size: 13px; 282 + line-height: 1.5; 283 + } 284 + .flag { 285 + font-size: 12px; 286 + padding: 7px 10px; 287 + border-radius: var(--r-sm); 288 + background: var(--surface-2); 289 + border-left: 3px solid var(--line-strong); 290 + display: flex; 291 + flex-wrap: wrap; 292 + gap: 6px; 293 + align-items: baseline; 294 + } 295 + .flag.high { 296 + border-left-color: var(--danger); 297 + background: var(--danger-dim); 298 + } 299 + .flag.medium { 300 + border-left-color: var(--normal); 301 + } 302 + .metrics { 303 + display: flex; 304 + flex-wrap: wrap; 305 + gap: 10px; 306 + padding-top: 12px; 307 + border-top: 1px solid var(--line); 308 + } 309 + .metric { 310 + display: flex; 311 + flex-direction: column; 312 + gap: 1px; 313 + padding: 6px 14px 6px 0; 314 + min-width: 110px; 315 + } 316 + .metric .dim { 317 + font-size: 11px; 318 + text-transform: uppercase; 319 + letter-spacing: 0.03em; 320 + } 321 + .metric b { 322 + font-size: 17px; 323 + } 324 + .metric b.warn { 325 + color: var(--normal); 326 + } 327 + </style>
+46
web/src/lib/components/StatCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + 4 + let { 5 + label, 6 + value, 7 + sub = '', 8 + accent = 'var(--fg)', 9 + icon, 10 + }: { 11 + label: string; 12 + value: string | number; 13 + sub?: string; 14 + accent?: string; 15 + icon?: Snippet; 16 + } = $props(); 17 + </script> 18 + 19 + <div class="card stat"> 20 + <div class="row" style="justify-content:space-between"> 21 + <span class="muted" style="font-size:12px; text-transform:uppercase; letter-spacing:0.04em" 22 + >{label}</span 23 + > 24 + {#if icon}<span class="ico" style="color:{accent}">{@render icon()}</span>{/if} 25 + </div> 26 + <b class="num" style="color:{accent}">{value}</b> 27 + {#if sub}<span class="dim" style="font-size:12px">{sub}</span>{/if} 28 + </div> 29 + 30 + <style> 31 + .stat { 32 + padding: 14px 16px; 33 + display: flex; 34 + flex-direction: column; 35 + gap: 4px; 36 + } 37 + b { 38 + font-size: 28px; 39 + font-weight: 700; 40 + line-height: 1.1; 41 + } 42 + .ico :global(svg) { 43 + width: 16px; 44 + height: 16px; 45 + } 46 + </style>
+252
web/src/lib/components/VouchGraph.svelte
··· 1 + <script lang="ts"> 2 + import type { GraphData } from '$lib/api'; 3 + import { DECISION_META } from '$lib/format'; 4 + import { num } from '$lib/format'; 5 + 6 + // ponytail: tiny built-in force sim (repel + link spring + center), not d3-force. 7 + // ~160 nodes × ~280 ticks is a few M float ops — runs once on mount, renders static SVG. 8 + // Add d3-force only if we need live dragging, quadtree scaling, or prettier layouts. 9 + let { data }: { data: GraphData } = $props(); 10 + 11 + const W = 760; 12 + const H = 460; 13 + const MAX = 160; // cap so the O(n²) sim stays snappy; trims to the most-trusted core 14 + 15 + const view = $derived.by(() => { 16 + const top = [...data.nodes].sort((a, b) => b.trust - a.trust).slice(0, MAX); 17 + const ids = new Set(top.map((n) => n.id)); 18 + const links = data.links.filter((l) => ids.has(l.source) && ids.has(l.target)); 19 + return { nodes: top, links, hidden: Math.max(0, data.nodes.length - top.length) }; 20 + }); 21 + 22 + let pos = $state<Record<string, { x: number; y: number }>>({}); 23 + let hover = $state<string | null>(null); 24 + const ready = $derived(Object.keys(pos).length > 0); 25 + const radius = (t: number) => 4 + t * 13; 26 + 27 + $effect(() => { 28 + const nodes = view.nodes; 29 + const N = nodes.length; 30 + if (!N) { 31 + pos = {}; 32 + return; 33 + } 34 + const idx = new Map(nodes.map((n, i) => [n.id, i])); 35 + // deterministic ring init (no Math.random) -> stable layout each render 36 + const p = nodes.map((n, i) => ({ 37 + x: W / 2 + Math.cos((i / N) * 2 * Math.PI) * 200 + ((i % 7) - 3), 38 + y: H / 2 + Math.sin((i / N) * 2 * Math.PI) * 130 + ((i % 5) - 2), 39 + vx: 0, 40 + vy: 0, 41 + })); 42 + const links = view.links 43 + .map((l) => [idx.get(l.source)!, idx.get(l.target)!] as const) 44 + .filter(([a, b]) => a != null && b != null); 45 + 46 + const REP = 2600; 47 + const SPRING = 0.018; 48 + const LINK_LEN = 64; 49 + const CENTER = 0.006; 50 + const DAMP = 0.86; 51 + for (let t = 0; t < 280; t++) { 52 + for (let i = 0; i < N; i++) { 53 + for (let j = i + 1; j < N; j++) { 54 + const dx = p[i].x - p[j].x; 55 + const dy = p[i].y - p[j].y; 56 + const d2 = dx * dx + dy * dy || 0.01; 57 + const d = Math.sqrt(d2); 58 + const f = REP / d2; 59 + const fx = (dx / d) * f; 60 + const fy = (dy / d) * f; 61 + p[i].vx += fx; 62 + p[i].vy += fy; 63 + p[j].vx -= fx; 64 + p[j].vy -= fy; 65 + } 66 + } 67 + for (const [a, b] of links) { 68 + const dx = p[b].x - p[a].x; 69 + const dy = p[b].y - p[a].y; 70 + const d = Math.sqrt(dx * dx + dy * dy) || 0.01; 71 + const f = (d - LINK_LEN) * SPRING; 72 + const fx = (dx / d) * f; 73 + const fy = (dy / d) * f; 74 + p[a].vx += fx; 75 + p[a].vy += fy; 76 + p[b].vx -= fx; 77 + p[b].vy -= fy; 78 + } 79 + for (let i = 0; i < N; i++) { 80 + p[i].vx += (W / 2 - p[i].x) * CENTER; 81 + p[i].vy += (H / 2 - p[i].y) * CENTER; 82 + p[i].vx *= DAMP; 83 + p[i].vy *= DAMP; 84 + p[i].x = Math.max(14, Math.min(W - 14, p[i].x + p[i].vx)); 85 + p[i].y = Math.max(14, Math.min(H - 14, p[i].y + p[i].vy)); 86 + } 87 + } 88 + const out: Record<string, { x: number; y: number }> = {}; 89 + nodes.forEach((n, i) => (out[n.id] = { x: p[i].x, y: p[i].y })); 90 + pos = out; 91 + }); 92 + 93 + // neighbours of the hovered node, to fade the rest 94 + const near = $derived.by(() => { 95 + if (!hover) return null; 96 + const s = new Set<string>([hover]); 97 + for (const l of view.links) { 98 + if (l.source === hover) s.add(l.target); 99 + if (l.target === hover) s.add(l.source); 100 + } 101 + return s; 102 + }); 103 + </script> 104 + 105 + <div class="wrap"> 106 + {#if ready} 107 + <svg viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Vouch graph"> 108 + <g class="edges"> 109 + {#each view.links as l, i (i)} 110 + {@const a = pos[l.source]} 111 + {@const b = pos[l.target]} 112 + {#if a && b} 113 + <line 114 + x1={a.x} 115 + y1={a.y} 116 + x2={b.x} 117 + y2={b.y} 118 + class:denounce={l.polarity < 0} 119 + class:hot={hover && (l.source === hover || l.target === hover)} 120 + class:fade={near && !(near.has(l.source) && near.has(l.target))} 121 + /> 122 + {/if} 123 + {/each} 124 + </g> 125 + <g class="nodes"> 126 + {#each view.nodes as n (n.id)} 127 + {@const p = pos[n.id]} 128 + {#if p} 129 + <g 130 + transform="translate({p.x},{p.y})" 131 + class:fade={near && !near.has(n.id)} 132 + role="button" 133 + tabindex="-1" 134 + aria-label="{n.handle} · trust {(n.trust * 100).toFixed(0)}%" 135 + onmouseenter={() => (hover = n.id)} 136 + onmouseleave={() => (hover = null)} 137 + > 138 + {#if n.seed}<circle class="ring" r={radius(n.trust) + 3.5} />{/if} 139 + <circle r={radius(n.trust)} fill={DECISION_META[n.decision].color} class="node" /> 140 + {#if hover === n.id} 141 + <text y={-radius(n.trust) - 7} text-anchor="middle" class="lbl">{n.handle}</text> 142 + {/if} 143 + </g> 144 + {/if} 145 + {/each} 146 + </g> 147 + </svg> 148 + {:else if !view.nodes.length} 149 + <div class="empty dim">No vouches in the graph yet — nothing to lay out.</div> 150 + {:else} 151 + <div class="empty dim">Laying out {view.nodes.length} nodes…</div> 152 + {/if} 153 + 154 + <div class="legend"> 155 + {#each Object.entries(DECISION_META) as [, meta] (meta.label)} 156 + <span class="leg"><span class="sw" style="background:{meta.color}"></span>{meta.label}</span> 157 + {/each} 158 + <span class="leg"><span class="sw ring-key"></span>seed</span> 159 + <span class="leg dim">size = trust</span> 160 + {#if view.hidden}<span class="leg dim">· showing top {num(view.nodes.length)} of {num(data.nodes.length)}</span>{/if} 161 + </div> 162 + </div> 163 + 164 + <style> 165 + .wrap { 166 + display: flex; 167 + flex-direction: column; 168 + gap: 8px; 169 + } 170 + svg { 171 + width: 100%; 172 + height: auto; 173 + background: 174 + radial-gradient(circle at 50% 40%, var(--surface-2), var(--surface) 70%); 175 + border-radius: var(--r); 176 + border: 1px solid var(--line); 177 + } 178 + .edges line { 179 + stroke: var(--line-strong); 180 + stroke-width: 1; 181 + opacity: 0.5; 182 + transition: opacity 0.15s; 183 + } 184 + .edges line.denounce { 185 + stroke: var(--danger); 186 + stroke-dasharray: 3 3; 187 + } 188 + .edges line.hot { 189 + stroke: var(--accent); 190 + opacity: 0.9; 191 + stroke-width: 1.4; 192 + } 193 + .edges line.fade { 194 + opacity: 0.06; 195 + } 196 + .nodes g { 197 + cursor: pointer; 198 + transition: opacity 0.15s; 199 + } 200 + .nodes g.fade { 201 + opacity: 0.22; 202 + } 203 + circle.node { 204 + stroke: var(--bg); 205 + stroke-width: 1.5; 206 + } 207 + .nodes g:hover circle.node { 208 + stroke: var(--fg); 209 + } 210 + .ring { 211 + fill: none; 212 + stroke: var(--fg); 213 + stroke-width: 1.5; 214 + opacity: 0.85; 215 + } 216 + .lbl { 217 + fill: var(--fg); 218 + font-size: 11px; 219 + font-weight: 600; 220 + paint-order: stroke; 221 + stroke: var(--bg); 222 + stroke-width: 3px; 223 + } 224 + .empty { 225 + height: 200px; 226 + display: grid; 227 + place-items: center; 228 + border: 1px dashed var(--line); 229 + border-radius: var(--r); 230 + } 231 + .legend { 232 + display: flex; 233 + flex-wrap: wrap; 234 + gap: 14px; 235 + font-size: 12px; 236 + color: var(--fg-muted); 237 + } 238 + .leg { 239 + display: inline-flex; 240 + align-items: center; 241 + gap: 6px; 242 + } 243 + .sw { 244 + width: 11px; 245 + height: 11px; 246 + border-radius: 50%; 247 + } 248 + .sw.ring-key { 249 + background: none; 250 + border: 1.5px solid var(--fg); 251 + } 252 + </style>
+48
web/src/lib/format.ts
··· 1 + import type { Decision } from './api'; 2 + 3 + export const pct = (v: number, d = 0) => `${(v * 100).toFixed(d)}%`; 4 + 5 + export const num = (n: number) => n.toLocaleString(); 6 + 7 + /** did:plc:hwevmowznbiukdf6uk5dwrrq -> plc:hwev…dwrrq, keeps it scannable. */ 8 + export function shortDid(did: string): string { 9 + const body = did.replace(/^did:/, ''); 10 + const [method, id] = body.split(':'); 11 + if (!id) return body; 12 + return id.length > 12 ? `${method}:${id.slice(0, 4)}…${id.slice(-4)}` : `${method}:${id}`; 13 + } 14 + 15 + /** sh.tangled.repo.pull/3mofr2q3ggw22 -> pull/3mofr2q3ggw22 */ 16 + export const shortPr = (prId: string) => prId.split('/').slice(-1)[0] ?? prId; 17 + 18 + export const ensureHttp = (url: string) => (/^https?:\/\//i.test(url) ? url : `https://${url}`); 19 + 20 + /** https://www.oppi.li/blog -> oppi.li, for compact link chips. */ 21 + export function host(url: string): string { 22 + try { 23 + return new URL(ensureHttp(url)).hostname.replace(/^www\./, ''); 24 + } catch { 25 + return url.replace(/^https?:\/\//i, '').split('/')[0]; 26 + } 27 + } 28 + 29 + /** Tangled profile URL. ponytail: assumes the /@handle route; fix if Tangled differs. */ 30 + export const tangledProfile = (handle: string) => `https://tangled.org/@${handle}`; 31 + 32 + export const DECISIONS = ['needs_human', 'normal_queue', 'fast_lane'] as const; 33 + 34 + export const DECISION_META: Record<Decision, { label: string; short: string; color: string }> = { 35 + fast_lane: { label: 'Fast-lane', short: 'fast', color: 'var(--fast)' }, 36 + normal_queue: { label: 'Normal queue', short: 'normal', color: 'var(--normal)' }, 37 + needs_human: { label: 'Needs review', short: 'review', color: 'var(--danger)' }, 38 + }; 39 + 40 + /** microseconds-since-epoch -> "3m ago" / "just now", for the ingest cursor. */ 41 + export function ago(us: number | null): string { 42 + if (us == null) return 'never'; 43 + const sec = (Date.now() - us / 1000) / 1000; 44 + if (sec < 60) return 'just now'; 45 + if (sec < 3600) return `${Math.floor(sec / 60)}m ago`; 46 + if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`; 47 + return `${Math.floor(sec / 86400)}d ago`; 48 + }
+37
web/src/lib/identity.ts
··· 1 + import { SvelteMap } from 'svelte/reactivity'; 2 + 3 + // Real ATProto handles aren't in the scorer DB (contributors.handle is empty), but 4 + // the API's /identity/{did} resolves + caches them via the PLC directory. We upgrade 5 + // DIDs -> handles lazily for whatever rows are on screen, bounded so we don't hammer 6 + // plc.directory. ponytail: client cache only; the server lru_caches the PLC hit too. 7 + const cache = new SvelteMap<string, string | null>(); // did -> handle, null = resolved/none 8 + const queued = new Set<string>(); 9 + let active = 0; 10 + const MAX = 8; 11 + 12 + function pump() { 13 + while (active < MAX && queued.size) { 14 + const did = queued.values().next().value as string; 15 + queued.delete(did); 16 + active++; 17 + fetch(`/api/identity/${encodeURIComponent(did)}`) 18 + .then((r) => (r.ok ? r.json() : null)) 19 + .then((d) => cache.set(did, (d?.handle as string) ?? null)) 20 + .catch(() => cache.set(did, null)) 21 + .finally(() => { 22 + active--; 23 + pump(); 24 + }); 25 + } 26 + } 27 + 28 + /** Resolve real handles for these DIDs (deduped, cached, max 8 in flight). */ 29 + export function resolveHandles(dids: string[]) { 30 + for (const did of dids) if (!cache.has(did) && !queued.has(did)) queued.add(did); 31 + pump(); 32 + } 33 + 34 + /** Resolved handle, `null` if the DID has none, `undefined` while still pending. */ 35 + export function handleFor(did: string): string | null | undefined { 36 + return cache.get(did); 37 + }
+44
web/src/routes/+error.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + import IconAlert from '~icons/lucide/triangle-alert'; 4 + </script> 5 + 6 + <div class="card err"> 7 + <IconAlert style="width:28px;height:28px;color:var(--danger)" /> 8 + <h1>{page.status}</h1> 9 + <p class="muted">{page.error?.message ?? 'Something went wrong.'}</p> 10 + <p class="dim" style="font-size:12px"> 11 + Is the scoring API running? <code>trust-api</code> on :8003, or set <code>API_BASE</code>. 12 + </p> 13 + <a href="/" class="back">Back to triage</a> 14 + </div> 15 + 16 + <style> 17 + .err { 18 + max-width: 460px; 19 + margin: 80px auto; 20 + padding: 28px; 21 + text-align: center; 22 + display: flex; 23 + flex-direction: column; 24 + align-items: center; 25 + gap: 10px; 26 + } 27 + h1 { 28 + font-size: 40px; 29 + } 30 + code { 31 + background: var(--surface-3); 32 + padding: 1px 5px; 33 + border-radius: 4px; 34 + font-family: var(--mono); 35 + } 36 + .back { 37 + margin-top: 8px; 38 + padding: 8px 16px; 39 + border-radius: var(--r-sm); 40 + background: var(--accent-dim); 41 + color: var(--fg); 42 + border: 1px solid var(--accent); 43 + } 44 + </style>
+21
web/src/routes/+layout.svelte
··· 1 + <script lang="ts"> 2 + import '../app.css'; 3 + import Nav from '$lib/components/Nav.svelte'; 4 + import { Toaster } from 'svelte-sonner'; 5 + 6 + let { children } = $props(); 7 + </script> 8 + 9 + <Nav /> 10 + <Toaster theme="dark" position="top-right" richColors /> 11 + <main> 12 + {@render children()} 13 + </main> 14 + 15 + <style> 16 + main { 17 + max-width: 1180px; 18 + margin: 0 auto; 19 + padding: 24px; 20 + } 21 + </style>
+505
web/src/routes/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { PageData } from './$types'; 3 + import type { Decision } from '$lib/api'; 4 + import { pct, shortDid, shortPr, num, DECISION_META } from '$lib/format'; 5 + import { resolveHandles, handleFor } from '$lib/identity'; 6 + import DecisionPill from '$lib/components/DecisionPill.svelte'; 7 + import StatCard from '$lib/components/StatCard.svelte'; 8 + import PrDetail from '$lib/components/PrDetail.svelte'; 9 + import Avatar from '$lib/components/Avatar.svelte'; 10 + import { ToggleGroup } from 'bits-ui'; 11 + import { toast } from 'svelte-sonner'; 12 + import IconSearch from '~icons/lucide/search'; 13 + import IconChevron from '~icons/lucide/chevron-down'; 14 + import IconCopy from '~icons/lucide/copy'; 15 + import IconFlag from '~icons/lucide/flag'; 16 + import IconLock from '~icons/lucide/lock'; 17 + import IconGit from '~icons/lucide/git-pull-request'; 18 + import IconClock from '~icons/lucide/clock'; 19 + import IconCheck from '~icons/lucide/check-check'; 20 + 21 + let { data }: { data: PageData } = $props(); 22 + const rows = $derived(data.rows); 23 + const PER = 50; 24 + 25 + let search = $state(''); 26 + let filter = $state<string>('all'); // 'all' | Decision; '' when deselected 27 + let sort = $state<'score_desc' | 'score_asc' | 'trust_desc'>('score_desc'); 28 + let pageNo = $state(0); 29 + let expanded = $state<Set<string>>(new Set()); 30 + 31 + const counts = $derived.by(() => { 32 + const c: Record<Decision, number> = { fast_lane: 0, normal_queue: 0, needs_human: 0 }; 33 + for (const r of rows) c[r.decision]++; 34 + return c; 35 + }); 36 + 37 + const q = $derived(search.trim().toLowerCase()); 38 + const filtered = $derived( 39 + rows.filter( 40 + (r) => 41 + (filter === 'all' || filter === '' || r.decision === filter) && 42 + (q === '' || 43 + `${r.profile?.name ?? ''} ${r.handle ?? ''} ${r.did} ${r.repo ?? ''} ${r.pr_id}` 44 + .toLowerCase() 45 + .includes(q)), 46 + ), 47 + ); 48 + const sorted = $derived.by(() => { 49 + const a = [...filtered]; 50 + if (sort === 'score_desc') a.sort((x, y) => y.calibrated_prob - x.calibrated_prob); 51 + else if (sort === 'score_asc') a.sort((x, y) => x.calibrated_prob - y.calibrated_prob); 52 + else a.sort((x, y) => y.explanation.structural_trust - x.explanation.structural_trust); 53 + return a; 54 + }); 55 + const pageCount = $derived(Math.max(1, Math.ceil(sorted.length / PER))); 56 + const paged = $derived(sorted.slice(pageNo * PER, pageNo * PER + PER)); 57 + 58 + // any filter/sort change starts over at page 1 59 + $effect(() => { 60 + [filter, q, sort]; 61 + pageNo = 0; 62 + }); 63 + 64 + // lazily resolve real handles for whatever rows are on screen 65 + $effect(() => { 66 + resolveHandles(paged.map((r) => r.did)); 67 + }); 68 + 69 + function toggle(id: string) { 70 + const next = new Set(expanded); 71 + if (next.has(id)) next.delete(id); 72 + else next.add(id); 73 + expanded = next; 74 + } 75 + 76 + async function copyDid(did: string) { 77 + try { 78 + await navigator.clipboard.writeText(did); 79 + toast.success('DID copied', { description: did }); 80 + } catch { 81 + toast.error('Copy failed'); 82 + } 83 + } 84 + 85 + const FILTERS = [ 86 + { v: 'all', label: 'All' }, 87 + { v: 'needs_human', label: 'Needs review' }, 88 + { v: 'normal_queue', label: 'Normal' }, 89 + { v: 'fast_lane', label: 'Fast-lane' }, 90 + ]; 91 + </script> 92 + 93 + <svelte:head><title>Triage queue · Tangled trust</title></svelte:head> 94 + 95 + <div class="head"> 96 + <h1>Triage queue</h1> 97 + <p class="muted">Open PRs ranked by how much the gate trusts their author — expand any row for the full provenance.</p> 98 + </div> 99 + 100 + <div class="strip"> 101 + <StatCard label="Open PRs" value={num(rows.length)} sub="awaiting a decision" icon={gitIcon} /> 102 + <StatCard 103 + label="Fast-lane" 104 + value={num(counts.fast_lane)} 105 + sub="auto-mergeable" 106 + accent="var(--fast)" 107 + icon={fastIcon} 108 + /> 109 + <StatCard 110 + label="Normal queue" 111 + value={num(counts.normal_queue)} 112 + sub="standard review" 113 + accent="var(--normal)" 114 + icon={clockIcon} 115 + /> 116 + <StatCard 117 + label="Needs review" 118 + value={num(counts.needs_human)} 119 + sub="human required" 120 + accent="var(--danger)" 121 + icon={flagIcon} 122 + /> 123 + </div> 124 + 125 + {#snippet gitIcon()}<IconGit />{/snippet} 126 + {#snippet fastIcon()}<IconCheck />{/snippet} 127 + {#snippet clockIcon()}<IconClock />{/snippet} 128 + {#snippet flagIcon()}<IconFlag />{/snippet} 129 + 130 + <div class="toolbar card"> 131 + <label class="searchbox"> 132 + <IconSearch style="width:15px;height:15px;color:var(--fg-dim)" /> 133 + <input placeholder="Search handle, DID, repo, PR…" bind:value={search} /> 134 + </label> 135 + 136 + <ToggleGroup.Root type="single" bind:value={filter} class="seg"> 137 + {#each FILTERS as f (f.v)} 138 + <ToggleGroup.Item value={f.v} class="seg-item">{f.label}</ToggleGroup.Item> 139 + {/each} 140 + </ToggleGroup.Root> 141 + 142 + <select bind:value={sort} class="sort" aria-label="Sort"> 143 + <option value="score_desc">Score ↓</option> 144 + <option value="score_asc">Score ↑</option> 145 + <option value="trust_desc">Structural ↓</option> 146 + </select> 147 + </div> 148 + 149 + <div class="card table"> 150 + <div class="trow thead"> 151 + <span>Score</span> 152 + <span>Author</span> 153 + <span>Target</span> 154 + <span>Trust</span> 155 + <span>Signals</span> 156 + <span></span> 157 + </div> 158 + 159 + {#each paged as r (r.pr_id)} 160 + {@const open = expanded.has(r.pr_id)} 161 + {@const e = r.explanation} 162 + {@const repoLabel = r.repo ?? r.pr_id.split('/')[0]} 163 + {@const hops = e.trust_path.length} 164 + {@const name = r.profile?.name ?? handleFor(r.did) ?? r.handle ?? shortDid(r.did)} 165 + {@const highFlags = (e.flags ?? []).filter((f) => f.severity === 'high').length} 166 + <div class="item" class:open> 167 + <div 168 + class="trow" 169 + role="button" 170 + tabindex="0" 171 + aria-expanded={open} 172 + onclick={() => toggle(r.pr_id)} 173 + onkeydown={(e) => { 174 + if (e.key === 'Enter' || e.key === ' ') { 175 + e.preventDefault(); 176 + toggle(r.pr_id); 177 + } 178 + }} 179 + > 180 + <span class="score"> 181 + <b class="num" style="color:{DECISION_META[r.decision].color}">{pct(r.calibrated_prob)}</b> 182 + <DecisionPill decision={r.decision} /> 183 + </span> 184 + 185 + <span class="author"> 186 + <Avatar did={r.did} cid={r.profile?.avatar_cid} {name} size={30} /> 187 + <span class="who truncate"> 188 + <span class="handle truncate" title={name}>{name}</span> 189 + <span class="did mono dim truncate" title={r.did}>{shortDid(r.did)}</span> 190 + </span> 191 + </span> 192 + 193 + <span class="target truncate"> 194 + <span class="repo truncate mono" title={repoLabel} class:dim={!r.repo} 195 + >{shortDid(repoLabel)}{r.repo ? '' : ' (no target)'}</span 196 + > 197 + <span class="pr mono dim">{shortPr(r.pr_id)}</span> 198 + </span> 199 + 200 + <span class="trust"> 201 + <b class="num">{pct(e.structural_trust)}</b> 202 + <span class="dim">{hops ? `${hops} hop${hops > 1 ? 's' : ''}` : 'no path'}</span> 203 + </span> 204 + 205 + <span class="signals"> 206 + {#if highFlags} 207 + <span class="chip danger"><IconFlag style="width:12px;height:12px" />{highFlags} high</span> 208 + {:else if (e.flags ?? []).length} 209 + <span class="chip"><IconFlag style="width:12px;height:12px" />{e.flags?.length}</span> 210 + {/if} 211 + {#if e.content_risk != null} 212 + <span class="chip" class:warn={e.content_risk > 0.2}>risk {pct(e.content_risk)}</span> 213 + {/if} 214 + {#if e.slop_similarity != null && e.slop_similarity >= 0.5} 215 + <span class="chip warn">slop {pct(e.slop_similarity)}</span> 216 + {/if} 217 + {#if e.compliance_block} 218 + <span class="chip warn"><IconLock style="width:11px;height:11px" />attest</span> 219 + {/if} 220 + <button 221 + class="copy" 222 + title="Copy author DID" 223 + onclick={(ev) => { 224 + ev.stopPropagation(); 225 + copyDid(r.did); 226 + }}><IconCopy style="width:13px;height:13px" /></button 227 + > 228 + </span> 229 + 230 + <span class="chev" class:open><IconChevron style="width:16px;height:16px" /></span> 231 + </div> 232 + {#if open}<PrDetail row={r} />{/if} 233 + </div> 234 + {/each} 235 + 236 + {#if !paged.length} 237 + <div class="empty muted">No PRs match this filter.</div> 238 + {/if} 239 + </div> 240 + 241 + <div class="pager"> 242 + <span class="dim"> 243 + {#if sorted.length} 244 + Showing {pageNo * PER + 1}–{Math.min((pageNo + 1) * PER, sorted.length)} of {num(sorted.length)} 245 + {filter !== 'all' && filter !== '' ? 'filtered ' : ''}PRs 246 + {:else} 247 + No results 248 + {/if} 249 + </span> 250 + <div class="row"> 251 + <button disabled={pageNo === 0} onclick={() => (pageNo = Math.max(0, pageNo - 1))}>Prev</button> 252 + <span class="dim">Page {pageNo + 1} / {pageCount}</span> 253 + <button disabled={pageNo >= pageCount - 1} onclick={() => (pageNo = Math.min(pageCount - 1, pageNo + 1))} 254 + >Next</button 255 + > 256 + </div> 257 + </div> 258 + 259 + <style> 260 + .head { 261 + margin-bottom: 16px; 262 + } 263 + .head h1 { 264 + font-size: 22px; 265 + } 266 + .head p { 267 + margin-top: 3px; 268 + font-size: 13px; 269 + } 270 + .strip { 271 + display: grid; 272 + grid-template-columns: repeat(4, 1fr); 273 + gap: 12px; 274 + margin-bottom: 16px; 275 + } 276 + @media (max-width: 720px) { 277 + .strip { 278 + grid-template-columns: repeat(2, 1fr); 279 + } 280 + } 281 + 282 + .toolbar { 283 + display: flex; 284 + gap: 10px; 285 + align-items: center; 286 + padding: 10px 12px; 287 + margin-bottom: 14px; 288 + flex-wrap: wrap; 289 + } 290 + .searchbox { 291 + display: flex; 292 + align-items: center; 293 + gap: 8px; 294 + flex: 1; 295 + min-width: 200px; 296 + padding: 7px 11px; 297 + background: var(--surface-2); 298 + border: 1px solid var(--line); 299 + border-radius: var(--r-sm); 300 + } 301 + .searchbox input { 302 + border: 0; 303 + background: none; 304 + outline: none; 305 + width: 100%; 306 + } 307 + .searchbox:focus-within { 308 + border-color: var(--accent); 309 + } 310 + :global(.seg) { 311 + display: flex; 312 + background: var(--surface-2); 313 + border: 1px solid var(--line); 314 + border-radius: var(--r-sm); 315 + padding: 2px; 316 + gap: 2px; 317 + } 318 + :global(.seg-item) { 319 + padding: 5px 11px; 320 + border-radius: 5px; 321 + background: none; 322 + border: 0; 323 + color: var(--fg-muted); 324 + font-size: 12.5px; 325 + font-weight: 550; 326 + cursor: pointer; 327 + white-space: nowrap; 328 + } 329 + :global(.seg-item:hover) { 330 + color: var(--fg); 331 + } 332 + :global(.seg-item[data-state='on']) { 333 + background: var(--surface-3); 334 + color: var(--fg); 335 + box-shadow: var(--shadow); 336 + } 337 + .sort { 338 + padding: 7px 10px; 339 + background: var(--surface-2); 340 + border: 1px solid var(--line); 341 + border-radius: var(--r-sm); 342 + cursor: pointer; 343 + } 344 + 345 + .table { 346 + overflow: hidden; 347 + } 348 + .trow { 349 + display: grid; 350 + grid-template-columns: 150px minmax(160px, 1.3fr) minmax(150px, 1.4fr) 90px minmax(150px, 1fr) 34px; 351 + align-items: center; 352 + gap: 14px; 353 + width: 100%; 354 + text-align: left; 355 + padding: 11px 16px; 356 + background: none; 357 + border: 0; 358 + border-bottom: 1px solid var(--line); 359 + cursor: pointer; 360 + color: inherit; 361 + } 362 + .thead { 363 + cursor: default; 364 + font-size: 11px; 365 + text-transform: uppercase; 366 + letter-spacing: 0.05em; 367 + color: var(--fg-dim); 368 + background: var(--surface-2); 369 + } 370 + .item:last-child .trow { 371 + border-bottom: 0; 372 + } 373 + .item.open > .trow { 374 + background: var(--surface-2); 375 + border-bottom-color: transparent; 376 + } 377 + .item:hover > .trow { 378 + background: var(--surface-2); 379 + } 380 + 381 + .score { 382 + display: flex; 383 + flex-direction: column; 384 + gap: 4px; 385 + align-items: flex-start; 386 + } 387 + .score b { 388 + font-size: 18px; 389 + line-height: 1; 390 + } 391 + .author { 392 + display: flex; 393 + align-items: center; 394 + gap: 9px; 395 + min-width: 0; 396 + } 397 + .author .who { 398 + display: flex; 399 + flex-direction: column; 400 + gap: 1px; 401 + min-width: 0; 402 + } 403 + .target, 404 + .trust { 405 + display: flex; 406 + flex-direction: column; 407 + gap: 1px; 408 + min-width: 0; 409 + } 410 + .handle { 411 + font-weight: 600; 412 + } 413 + .did, 414 + .pr { 415 + font-size: 11.5px; 416 + } 417 + .repo { 418 + font-size: 13px; 419 + } 420 + .trust b { 421 + font-size: 14px; 422 + } 423 + .trust .dim { 424 + font-size: 11.5px; 425 + } 426 + 427 + .signals { 428 + display: flex; 429 + align-items: center; 430 + gap: 5px; 431 + flex-wrap: wrap; 432 + } 433 + .signals .chip { 434 + font-size: 11px; 435 + padding: 1px 7px; 436 + } 437 + .chip.warn { 438 + color: var(--normal); 439 + border-color: color-mix(in srgb, var(--normal) 30%, transparent); 440 + } 441 + .chip.danger { 442 + color: var(--danger); 443 + border-color: color-mix(in srgb, var(--danger) 35%, transparent); 444 + background: var(--danger-dim); 445 + } 446 + .copy { 447 + background: none; 448 + border: 0; 449 + color: var(--fg-dim); 450 + cursor: pointer; 451 + padding: 3px; 452 + border-radius: 5px; 453 + } 454 + .copy:hover { 455 + color: var(--fg); 456 + background: var(--surface-3); 457 + } 458 + .chev { 459 + color: var(--fg-dim); 460 + transition: transform 0.15s ease; 461 + display: flex; 462 + justify-content: center; 463 + } 464 + .chev.open { 465 + transform: rotate(180deg); 466 + color: var(--fg); 467 + } 468 + .empty { 469 + padding: 40px; 470 + text-align: center; 471 + } 472 + 473 + .pager { 474 + display: flex; 475 + justify-content: space-between; 476 + align-items: center; 477 + margin-top: 14px; 478 + font-size: 13px; 479 + } 480 + .pager button { 481 + padding: 6px 14px; 482 + background: var(--surface-2); 483 + border: 1px solid var(--line); 484 + border-radius: var(--r-sm); 485 + cursor: pointer; 486 + } 487 + .pager button:hover:not(:disabled) { 488 + border-color: var(--line-strong); 489 + background: var(--surface-3); 490 + } 491 + .pager button:disabled { 492 + opacity: 0.4; 493 + cursor: not-allowed; 494 + } 495 + 496 + @media (max-width: 760px) { 497 + .trow { 498 + grid-template-columns: 110px 1fr 30px; 499 + } 500 + .target, 501 + .trust { 502 + display: none; 503 + } 504 + } 505 + </style>
+9
web/src/routes/+page.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { getJson, TriageList } from '$lib/api'; 3 + import type { PageLoad } from './$types'; 4 + 5 + export const load: PageLoad = async ({ fetch }) => { 6 + const res = await getJson(fetch, '/triage', TriageList); 7 + if (res.isErr()) throw error(502, res.error); 8 + return { rows: res.value }; 9 + };
+15
web/src/routes/api/[...path]/+server.ts
··· 1 + import { env } from '$env/dynamic/private'; 2 + import type { RequestHandler } from './$types'; 3 + 4 + // Single proxy so dev and prod (adapter-node) both talk to the FastAPI scoring 5 + // service via same-origin /api/* — no CORS, no dev/prod config drift. 6 + // ponytail: GET-only; the UI is read-only. Add POST when /review/pr gets a form. 7 + const BASE = env.API_BASE ?? 'http://127.0.0.1:8003'; 8 + 9 + export const GET: RequestHandler = async ({ params, url, fetch }) => { 10 + const res = await fetch(`${BASE}/${params.path}${url.search}`); 11 + return new Response(res.body, { 12 + status: res.status, 13 + headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' }, 14 + }); 15 + };
+181
web/src/routes/backfill/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getJson, BackfillStatus, type BackfillStatus as Status } from '$lib/api'; 4 + import { num } from '$lib/format'; 5 + import StatCard from '$lib/components/StatCard.svelte'; 6 + import IconUsers from '~icons/lucide/users'; 7 + import IconLink from '~icons/lucide/link'; 8 + import IconGit from '~icons/lucide/git-pull-request'; 9 + 10 + let status = $state<Status | null>(null); 11 + let offline = $state(false); 12 + let rate = $state(0); 13 + let prev: { total: number; t: number } | null = null; 14 + let peak = $state(1); 15 + 16 + const collections = $derived( 17 + status ? Object.entries(status.collections).sort((a, b) => b[1] - a[1]) : [], 18 + ); 19 + const active = $derived(rate >= 0.5); 20 + 21 + async function tick() { 22 + const res = await getJson(fetch, '/backfill/status', BackfillStatus); 23 + if (res.isErr()) { 24 + offline = true; 25 + return; 26 + } 27 + offline = false; 28 + const d = res.value; 29 + const now = Date.now(); 30 + if (prev && now > prev.t) rate = ((d.total - prev.total) / (now - prev.t)) * 1000; 31 + prev = { total: d.total, t: now }; 32 + peak = Math.max(peak, ...Object.values(d.collections), 1); 33 + status = d; 34 + } 35 + 36 + onMount(() => { 37 + tick(); 38 + const id = setInterval(tick, 1500); 39 + return () => clearInterval(id); 40 + }); 41 + </script> 42 + 43 + <svelte:head><title>Backfill · Tangled trust</title></svelte:head> 44 + 45 + <div class="head"> 46 + <div class="row"> 47 + <span class="dot" class:on={active && !offline} class:off={offline}></span> 48 + <h1>Backfill</h1> 49 + </div> 50 + <p class="muted">Live scrape progress — record counts per collection in the raw event mirror.</p> 51 + </div> 52 + 53 + {#if offline && !status} 54 + <div class="card off-card"> 55 + <b>Scraper offline</b> 56 + <span class="dim">No response from <code>/backfill/status</code>. Is the API up on :8000?</span> 57 + </div> 58 + {:else if !status} 59 + <div class="card off-card"><span class="dim">Connecting…</span></div> 60 + {:else} 61 + <div class="strip"> 62 + <StatCard 63 + label="Total records" 64 + value={num(status.total)} 65 + sub={offline ? 'reconnecting…' : active ? `+${rate.toFixed(0)}/s` : 'idle'} 66 + accent="var(--accent)" 67 + /> 68 + <StatCard label="Contributors" value={num(status.derived.contributors)} sub="derived" icon={users} /> 69 + <StatCard label="Vouches" value={num(status.derived.vouches)} sub="derived" accent="var(--fast)" icon={link} /> 70 + <StatCard label="Pull requests" value={num(status.derived.pull_requests)} sub="derived" accent="var(--normal)" icon={git} /> 71 + </div> 72 + 73 + {#snippet users()}<IconUsers />{/snippet} 74 + {#snippet link()}<IconLink />{/snippet} 75 + {#snippet git()}<IconGit />{/snippet} 76 + 77 + <div class="card cols"> 78 + {#each collections as [name, n] (name)} 79 + <div class="crow" class:zero={n === 0}> 80 + <span class="cname mono truncate" title={name}>{name}</span> 81 + <div class="track"><div class="fill" style="width:{(n / peak) * 100}%"></div></div> 82 + <span class="cnum num">{num(n)}</span> 83 + </div> 84 + {/each} 85 + </div> 86 + {/if} 87 + 88 + <style> 89 + .head { 90 + margin-bottom: 16px; 91 + } 92 + .head h1 { 93 + font-size: 22px; 94 + } 95 + .head p { 96 + margin-top: 3px; 97 + font-size: 13px; 98 + } 99 + .dot { 100 + width: 9px; 101 + height: 9px; 102 + border-radius: 50%; 103 + background: var(--fg-dim); 104 + } 105 + .dot.on { 106 + background: var(--fast); 107 + box-shadow: 0 0 8px var(--fast); 108 + animation: pulse 1.5s infinite; 109 + } 110 + .dot.off { 111 + background: var(--danger); 112 + box-shadow: 0 0 8px var(--danger); 113 + } 114 + @keyframes pulse { 115 + 50% { 116 + opacity: 0.35; 117 + } 118 + } 119 + .strip { 120 + display: grid; 121 + grid-template-columns: repeat(4, 1fr); 122 + gap: 12px; 123 + margin-bottom: 16px; 124 + } 125 + @media (max-width: 720px) { 126 + .strip { 127 + grid-template-columns: repeat(2, 1fr); 128 + } 129 + } 130 + .off-card { 131 + padding: 28px; 132 + display: flex; 133 + flex-direction: column; 134 + gap: 6px; 135 + } 136 + code { 137 + font-family: var(--mono); 138 + background: var(--surface-3); 139 + padding: 1px 5px; 140 + border-radius: 4px; 141 + } 142 + .cols { 143 + padding: 12px 16px; 144 + display: flex; 145 + flex-direction: column; 146 + gap: 7px; 147 + } 148 + .crow { 149 + display: grid; 150 + grid-template-columns: 240px 1fr 90px; 151 + align-items: center; 152 + gap: 14px; 153 + } 154 + .cname { 155 + font-size: 12.5px; 156 + } 157 + .crow.zero .cname, 158 + .crow.zero .cnum { 159 + color: var(--fg-dim); 160 + } 161 + .track { 162 + height: 18px; 163 + background: var(--surface-2); 164 + border-radius: var(--r-sm); 165 + overflow: hidden; 166 + } 167 + .fill { 168 + height: 100%; 169 + background: linear-gradient(90deg, var(--accent-dim), var(--accent)); 170 + border-radius: var(--r-sm); 171 + transition: width 0.4s ease; 172 + } 173 + .cnum { 174 + text-align: right; 175 + } 176 + @media (max-width: 620px) { 177 + .crow { 178 + grid-template-columns: 140px 1fr 64px; 179 + } 180 + } 181 + </style>
+137
web/src/routes/dashboard/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { PageData } from './$types'; 3 + import { pct, num, ago } from '$lib/format'; 4 + import StatCard from '$lib/components/StatCard.svelte'; 5 + import BarHistogram from '$lib/components/BarHistogram.svelte'; 6 + import Donut from '$lib/components/Donut.svelte'; 7 + import VouchGraph from '$lib/components/VouchGraph.svelte'; 8 + import IconZap from '~icons/lucide/zap'; 9 + import IconShield from '~icons/lucide/shield-alert'; 10 + import IconUsers from '~icons/lucide/users'; 11 + import IconLink from '~icons/lucide/link'; 12 + import IconSprout from '~icons/lucide/sprout'; 13 + import IconClock from '~icons/lucide/clock'; 14 + 15 + let { data }: { data: PageData } = $props(); 16 + const m = $derived(data.metrics); 17 + 18 + const binLabels = Array.from({ length: 10 }, (_, i) => `.${i}`); 19 + const decisionSegments = $derived([ 20 + { label: 'Fast-lane', value: m.decisions.fast_lane, color: 'var(--fast)' }, 21 + { label: 'Normal queue', value: m.decisions.normal_queue, color: 'var(--normal)' }, 22 + { label: 'Needs review', value: m.decisions.needs_human, color: 'var(--danger)' }, 23 + ]); 24 + const faRate = $derived(m.false_approval_rate); 25 + </script> 26 + 27 + <svelte:head><title>Dashboard · Tangled trust</title></svelte:head> 28 + 29 + <div class="head"> 30 + <h1>Observability</h1> 31 + <p class="muted">How the gate is behaving across every scored contributor.</p> 32 + </div> 33 + 34 + <div class="strip"> 35 + <StatCard label="Fast-lane rate" value={pct(m.fast_lane_rate)} sub="{num(m.decisions.fast_lane)} contributors" accent="var(--fast)" icon={zap} /> 36 + <StatCard 37 + label="False-approval" 38 + value={faRate == null ? 'n/a' : pct(faRate, 1)} 39 + sub="fast-lane PRs not cleanly merged" 40 + accent={faRate && faRate > 0 ? 'var(--danger)' : 'var(--fg)'} 41 + icon={shield} 42 + /> 43 + <StatCard label="Contributors" value={num(m.vouch_graph.contributors)} sub="in the trust graph" icon={users} /> 44 + <StatCard label="Vouch edges" value={num(m.vouch_graph.edges)} sub="signed endorsements" accent="var(--accent)" icon={link} /> 45 + <StatCard label="Seed maintainers" value={num(m.vouch_graph.seeds)} sub="trust anchors" accent="var(--fast)" icon={sprout} /> 46 + <StatCard label="Ingest cursor" value={ago(m.ingest_last_time_us)} sub="last jetstream event" icon={clock} /> 47 + </div> 48 + 49 + {#snippet zap()}<IconZap />{/snippet} 50 + {#snippet shield()}<IconShield />{/snippet} 51 + {#snippet users()}<IconUsers />{/snippet} 52 + {#snippet link()}<IconLink />{/snippet} 53 + {#snippet sprout()}<IconSprout />{/snippet} 54 + {#snippet clock()}<IconClock />{/snippet} 55 + 56 + <div class="charts"> 57 + <div class="card panel"> 58 + <div class="row" style="justify-content:space-between"> 59 + <h2>Trust-score distribution</h2> 60 + <span class="dim" style="font-size:12px">{num(m.score_distribution.reduce((a, b) => a + b, 0))} scored</span> 61 + </div> 62 + <BarHistogram bins={m.score_distribution} labels={binLabels} /> 63 + <p class="dim foot">EigenTrust score, bucketed 0.0–0.9. A healthy graph leans right.</p> 64 + </div> 65 + 66 + <div class="card panel"> 67 + <h2>Decision split</h2> 68 + <Donut segments={decisionSegments} centerLabel="contributors" /> 69 + </div> 70 + 71 + <div class="card panel wide"> 72 + <div class="row" style="justify-content:space-between"> 73 + <h2>Vouch graph</h2> 74 + <span class="dim" style="font-size:12px"> 75 + {num(m.vouch_graph.contributors)} contributors · {num(m.vouch_graph.edges)} edges · {num(m.vouch_graph.seeds)} seeds 76 + </span> 77 + </div> 78 + {#if data.graph} 79 + <VouchGraph data={data.graph} /> 80 + {:else} 81 + <p class="dim">Graph data unavailable from the scoring API.</p> 82 + {/if} 83 + </div> 84 + </div> 85 + 86 + <style> 87 + .head { 88 + margin-bottom: 16px; 89 + } 90 + .head h1 { 91 + font-size: 22px; 92 + } 93 + .head p { 94 + margin-top: 3px; 95 + font-size: 13px; 96 + } 97 + .strip { 98 + display: grid; 99 + grid-template-columns: repeat(3, 1fr); 100 + gap: 12px; 101 + margin-bottom: 16px; 102 + } 103 + @media (max-width: 820px) { 104 + .strip { 105 + grid-template-columns: repeat(2, 1fr); 106 + } 107 + } 108 + .charts { 109 + display: grid; 110 + grid-template-columns: 1.6fr 1fr; 111 + gap: 16px; 112 + } 113 + @media (max-width: 820px) { 114 + .charts { 115 + grid-template-columns: 1fr; 116 + } 117 + } 118 + .panel { 119 + padding: 16px 18px; 120 + display: flex; 121 + flex-direction: column; 122 + gap: 14px; 123 + } 124 + .panel.wide { 125 + grid-column: 1 / -1; 126 + } 127 + h2 { 128 + font-size: 12px; 129 + text-transform: uppercase; 130 + letter-spacing: 0.04em; 131 + color: var(--fg-muted); 132 + } 133 + .foot { 134 + font-size: 12px; 135 + margin-top: -4px; 136 + } 137 + </style>
+14
web/src/routes/dashboard/+page.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { getJson, Metrics, GraphData } from '$lib/api'; 3 + import type { PageLoad } from './$types'; 4 + 5 + export const load: PageLoad = async ({ fetch }) => { 6 + // ?connected=1 trims to the vouched core; the default returns every contributor. 7 + const [mRes, gRes] = await Promise.all([ 8 + getJson(fetch, '/metrics', Metrics), 9 + getJson(fetch, '/graph?connected=1', GraphData), 10 + ]); 11 + if (mRes.isErr()) throw error(502, mRes.error); 12 + // graph is a nice-to-have: a failure there shouldn't blank the whole dashboard 13 + return { metrics: mRes.value, graph: gRes.isOk() ? gRes.value : null }; 14 + };
+195
web/src/routes/leaderboard/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { PageData } from './$types'; 3 + import { pct, shortDid, num, host, ensureHttp } from '$lib/format'; 4 + import { resolveHandles, handleFor } from '$lib/identity'; 5 + import DecisionPill from '$lib/components/DecisionPill.svelte'; 6 + import Avatar from '$lib/components/Avatar.svelte'; 7 + import { toast } from 'svelte-sonner'; 8 + import IconCopy from '~icons/lucide/copy'; 9 + import IconCrown from '~icons/lucide/crown'; 10 + import IconLink from '~icons/lucide/external-link'; 11 + 12 + let { data }: { data: PageData } = $props(); 13 + const rows = $derived(data.rows); 14 + const top = $derived(rows[0]?.calibrated_prob ?? 1); 15 + 16 + $effect(() => { 17 + resolveHandles(rows.map((r) => r.did)); 18 + }); 19 + 20 + async function copy(did: string) { 21 + try { 22 + await navigator.clipboard.writeText(did); 23 + toast.success('DID copied'); 24 + } catch { 25 + toast.error('Copy failed'); 26 + } 27 + } 28 + </script> 29 + 30 + <svelte:head><title>Leaderboard · Tangled trust</title></svelte:head> 31 + 32 + <div class="head"> 33 + <h1>Leaderboard</h1> 34 + <p class="muted">Contributors ranked by calibrated trust — the bar shows each one relative to the top.</p> 35 + </div> 36 + 37 + <div class="card list"> 38 + {#each rows as r, i (r.did)} 39 + {@const name = r.profile?.name ?? handleFor(r.did) ?? r.handle ?? shortDid(r.did)} 40 + <div class="rowx" class:podium={i < 3}> 41 + <span class="rank num"> 42 + {#if i === 0}<IconCrown style="width:15px;height:15px;color:var(--normal)" />{:else}{i + 1}{/if} 43 + </span> 44 + <div class="who"> 45 + <Avatar did={r.did} cid={r.profile?.avatar_cid} {name} size={34} /> 46 + <div class="whotext"> 47 + <span class="handle" title={name}>{name}</span> 48 + <span class="sub"> 49 + <span class="did mono dim" title={r.did}>{shortDid(r.did)}</span> 50 + <span class="vouches dim" title="positive vouches received" 51 + >{num(r.vouches)} vouch{r.vouches === 1 ? '' : 'es'}</span 52 + > 53 + {#each r.profile?.links ?? [] as l (l)} 54 + <a class="lnk dim" href={ensureHttp(l)} target="_blank" rel="noreferrer noopener" title={l} 55 + ><IconLink style="width:10px;height:10px" />{host(l)}</a 56 + > 57 + {/each} 58 + </span> 59 + </div> 60 + </div> 61 + <div class="barwrap"> 62 + <div class="bar" style="width:{(r.calibrated_prob / top) * 100}%"></div> 63 + </div> 64 + <span class="prob num">{pct(r.calibrated_prob, 1)}</span> 65 + <DecisionPill decision={r.decision} label /> 66 + <button class="copy" title="Copy DID" onclick={() => copy(r.did)} 67 + ><IconCopy style="width:13px;height:13px" /></button 68 + > 69 + </div> 70 + {/each} 71 + {#if !rows.length}<div class="empty muted">No contributors scored yet.</div>{/if} 72 + </div> 73 + 74 + <p class="dim foot">{num(rows.length)} ranked contributors.</p> 75 + 76 + <style> 77 + .head { 78 + margin-bottom: 16px; 79 + } 80 + .head h1 { 81 + font-size: 22px; 82 + } 83 + .head p { 84 + margin-top: 3px; 85 + font-size: 13px; 86 + } 87 + .list { 88 + overflow: hidden; 89 + } 90 + .rowx { 91 + display: grid; 92 + grid-template-columns: 42px minmax(150px, 1.3fr) minmax(120px, 2fr) 64px 110px 30px; 93 + align-items: center; 94 + gap: 14px; 95 + padding: 10px 16px; 96 + border-bottom: 1px solid var(--line); 97 + } 98 + .rowx:last-child { 99 + border-bottom: 0; 100 + } 101 + .rowx:hover { 102 + background: var(--surface-2); 103 + } 104 + .rowx.podium .handle { 105 + font-weight: 700; 106 + } 107 + .rank { 108 + display: flex; 109 + justify-content: center; 110 + color: var(--fg-dim); 111 + font-weight: 600; 112 + } 113 + .who { 114 + display: flex; 115 + align-items: center; 116 + gap: 10px; 117 + min-width: 0; 118 + } 119 + .whotext { 120 + display: flex; 121 + flex-direction: column; 122 + gap: 1px; 123 + min-width: 0; 124 + } 125 + .handle { 126 + font-weight: 600; 127 + overflow: hidden; 128 + text-overflow: ellipsis; 129 + white-space: nowrap; 130 + } 131 + .sub { 132 + display: flex; 133 + align-items: center; 134 + gap: 9px; 135 + min-width: 0; 136 + } 137 + .did { 138 + font-size: 11.5px; 139 + } 140 + .lnk { 141 + display: inline-flex; 142 + align-items: center; 143 + gap: 3px; 144 + font-size: 11.5px; 145 + color: var(--accent); 146 + } 147 + .lnk:hover { 148 + text-decoration: underline; 149 + } 150 + .barwrap { 151 + height: 8px; 152 + background: var(--surface-3); 153 + border-radius: var(--pill); 154 + overflow: hidden; 155 + } 156 + .bar { 157 + height: 100%; 158 + border-radius: var(--pill); 159 + background: linear-gradient(90deg, var(--accent), var(--fast)); 160 + transition: width 0.5s ease; 161 + } 162 + .prob { 163 + font-weight: 600; 164 + text-align: right; 165 + } 166 + .copy { 167 + background: none; 168 + border: 0; 169 + color: var(--fg-dim); 170 + cursor: pointer; 171 + padding: 3px; 172 + border-radius: 5px; 173 + } 174 + .copy:hover { 175 + color: var(--fg); 176 + background: var(--surface-3); 177 + } 178 + .empty { 179 + padding: 40px; 180 + text-align: center; 181 + } 182 + .foot { 183 + margin-top: 12px; 184 + font-size: 12px; 185 + } 186 + @media (max-width: 720px) { 187 + .rowx { 188 + grid-template-columns: 34px 1fr 56px 30px; 189 + } 190 + .barwrap, 191 + :global(.rowx .pill) { 192 + display: none; 193 + } 194 + } 195 + </style>
+9
web/src/routes/leaderboard/+page.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { getJson, LeaderList } from '$lib/api'; 3 + import type { PageLoad } from './$types'; 4 + 5 + export const load: PageLoad = async ({ fetch }) => { 6 + const res = await getJson(fetch, '/leaderboard?limit=100', LeaderList); 7 + if (res.isErr()) throw error(502, res.error); 8 + return { rows: res.value }; 9 + };
+12
web/static/logo.svg
··· 1 + <svg width="2000" height="408" viewBox="0 0 2000 408" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M1999.7 31.284C1999.71 121.876 1999.5 211.196 1999.98 300.512C2000.05 313.12 1996.99 319.042 1983.6 317.178C1978.15 316.419 1972.51 317.029 1966.95 317.018C1944.96 316.974 1944.96 316.974 1939.6 294.304C1911.86 320.982 1879.3 327.309 1843.67 318.057C1821.18 312.218 1801.93 300.189 1787.71 281.632C1754.42 238.201 1755.27 172.114 1789.1 131.561C1823.58 90.2123 1874.52 83.8607 1934.12 113.601C1938.63 110.045 1936.62 104.971 1936.66 100.637C1936.87 72.433 1937.33 44.212 1936.57 16.0271C1936.23 3.40498 1940.92 -0.719591 1952.96 0.120308C1963.59 0.861895 1974.35 0.741178 1984.99 0.0774576C1995.92 -0.604316 2001.12 3.13021 1999.8 14.6385C1999.21 19.6991 1999.7 24.8831 1999.7 31.284ZM1837.11 171.247C1813.95 205.707 1829.82 251.269 1861.9 263.08C1888.29 272.796 1918.1 261.889 1931.05 237.31C1945.22 210.415 1937.26 173.972 1913.72 158.024C1888.51 140.941 1859.3 145.454 1837.11 171.247Z" fill="#8B7CF6"/> 3 + <path d="M542.804 0.242838C555.192 -0.980057 559.083 4.20306 558.942 15.1614C558.52 48.0262 558.79 80.8998 558.79 112.796C561.664 115.132 563.087 114.171 564.458 113.183C599.792 87.7148 637.212 88.148 674.667 106.551C712.908 125.34 729.64 158.74 731.678 200.058C733.936 245.817 718.323 283.113 677.754 307.382C640.921 329.416 596.65 326.433 562.497 300.235C560.541 298.735 558.467 297.387 556.565 296.049C555.441 297.102 554.547 297.545 554.311 298.219C547.725 316.984 547.736 316.988 527.082 317.007C496.3 317.035 496.328 317.035 496.328 285.634C496.328 196.281 496.29 106.928 496.3 17.5748C496.302 0.512556 496.372 0.484824 513.381 0.327718C522.785 0.240841 532.191 0.265205 542.804 0.242838ZM562.39 233.89C569.058 248.102 579.488 258.34 594.451 263.446C618.543 271.668 644.057 263.633 658.259 243.605C673.636 221.919 673.2 189.316 656.733 169.3C643.236 152.892 625.665 145.787 604.462 149.004C581.932 152.421 567.597 165.825 560.374 186.969C555.224 202.042 555.652 217.188 562.39 233.89Z" fill="#8B7CF6"/> 4 + <path d="M781.664 291.041C748.231 256.649 739.148 216.811 752.339 172.223C765.146 128.928 795.991 103.91 839.283 96.4216C920.228 82.4201 977.823 132.039 977.874 211.328C977.88 219.867 976.478 226.528 965.489 226.478C923.574 226.284 881.657 226.446 839.741 226.392C836.875 226.388 833.604 226.72 830.945 221.35C857.176 206.881 875.225 177.368 911.237 182.015C907.832 160.468 888.685 145.918 865.186 145.13C840.381 144.299 818.148 159.436 813.548 180.867C827.235 185.083 842.166 179.437 859.059 185.733C844.874 195.982 832.914 204.581 820.999 213.243C807.415 223.119 806.883 225.486 814.612 240.431C832 274.052 882.681 279.78 907.094 250.146C913.325 242.584 918.638 241.811 926.714 244.74C937.557 248.673 948.5 252.412 959.597 255.534C968.505 258.04 968.826 262.124 964.597 269.472C944.165 304.972 896.573 326.815 851.984 321.38C825.771 318.184 802.351 309.087 781.664 291.041Z" fill="#8B7CF6"/> 5 + <path d="M1607.2 152.598C1596.72 159.661 1588.09 167.095 1584.77 180.334C1599.28 185.057 1614.44 180.054 1628.5 183.523C1630.14 188.803 1626.03 189.905 1623.54 191.698C1611.74 200.182 1599.88 208.587 1587.88 216.788C1582.04 220.784 1580.79 225.249 1583.21 232.104C1590.48 252.71 1605.32 264.613 1626.02 268.374C1647.03 272.192 1666.5 267.269 1680.18 249.823C1685.89 242.543 1691.07 241.783 1698.66 244.447C1712.26 249.224 1725.94 253.761 1740.02 258.544C1738.2 271.554 1731.06 280.275 1723.24 287.716C1685.72 323.425 1641.21 330.497 1593.63 314.296C1547.18 298.48 1521.68 263.1 1519.02 215.014C1514.91 140.411 1571.27 95.422 1629.46 94.4828C1646.22 94.2122 1662.71 94.9085 1678.71 100.783C1733.92 121.054 1749.66 170.497 1750.21 212.929C1750.31 220.38 1748.4 226.487 1738.86 226.461C1696.53 226.342 1654.21 226.422 1611.88 226.347C1609.39 226.343 1606.69 226.176 1603.48 221.727C1629.08 205.817 1647.74 178.128 1683.22 181.717C1678.95 150.53 1642.35 136.835 1607.2 152.598Z" fill="#8B7CF6"/> 6 + <path d="M180.67 108.566C205.218 122.257 221.342 141.708 231.23 169.082C212.474 173.98 194.934 178.772 177.271 183.055C171.426 184.472 169.453 179.123 166.88 175.25C151.066 151.448 127.172 142.605 101.471 150.996C76.5456 159.133 62.2767 182.59 63.9183 212.73C65.3201 238.468 83.7786 260.539 108.354 265.862C131.473 270.869 155.068 260.882 167.171 239.031C172.084 230.161 177.067 229.041 185.592 231.83C196.546 235.413 207.658 238.658 218.897 241.185C230.927 243.89 231.114 249.565 226.115 259.389C204.715 301.44 159.162 325.878 108.77 321.641C53.2701 316.974 12.5344 281.416 2.14917 228.572C-11.4518 159.365 41.2205 95.1868 111.568 94.3608C135.52 94.0796 158.451 96.66 180.67 108.566Z" fill="#8B7CF6"/> 7 + <path d="M1297.04 100.937C1326.71 111.773 1347.62 130.692 1360.83 158.381C1364.63 166.326 1362.91 170.288 1354.56 172.125C1347.47 173.683 1340.15 174.773 1333.46 177.415C1316.6 184.073 1304.4 184.851 1292.56 165.763C1280.21 145.847 1249.75 142.842 1227.41 154.001C1205.79 164.799 1192.49 192.386 1197.15 216.739C1202.25 243.316 1219.26 261.662 1242.94 266.127C1266.55 270.577 1288.17 260.175 1302.85 236.153C1306.67 229.891 1310.73 229.434 1316.57 231.115C1328.89 234.659 1341.16 238.375 1353.57 241.582C1363.39 244.122 1363.97 249.282 1360.18 257.701C1340.89 300.509 1288.32 328.149 1238.71 321.181C1177.88 312.638 1139.33 275.453 1134.76 214.34C1130.18 153.201 1170.77 101.437 1237.82 94.8586C1257.33 92.9439 1277.18 93.4848 1297.04 100.937Z" fill="#8B7CF6"/> 8 + <path d="M348.739 268.01C369.926 214.667 390.9 162.301 411.329 109.725C414.833 100.709 419.793 97.0193 429.381 97.5982C445.781 98.5884 462.352 96.6234 478.114 98.7292C480.546 105.561 477.402 109.58 475.726 113.773C438.149 207.813 400.41 301.788 363.002 395.894C359.858 403.803 355.761 407.367 347.025 407.083C330.092 406.531 313.129 406.922 293.118 406.922C312.294 358.998 330.317 313.957 348.739 268.01Z" fill="#8B7CF6"/> 9 + <path d="M1380.49 300.194C1380.5 237.833 1380.44 176.695 1380.57 115.557C1380.61 98.2863 1381.31 97.837 1398.88 97.7308C1405.29 97.6921 1411.7 97.6983 1418.12 97.6913C1438.64 97.6688 1438.63 97.6704 1439.36 117.737C1439.44 119.838 1439.69 121.933 1440.11 127.099C1450.91 112.317 1462.73 102.416 1478.14 97.6891C1480.59 96.9374 1483.03 96.1227 1485.53 95.5471C1514.18 88.9337 1519.34 92.9864 1519.15 121.886C1518.96 151.006 1518.96 151.006 1491.24 152.663C1467.9 154.058 1449.21 171.997 1445.22 197.079C1440.35 227.684 1443.52 258.521 1442.89 289.246C1442.32 317.019 1442.73 317.029 1414.89 317.02C1408.9 317.018 1402.86 316.466 1396.94 317.104C1384.92 318.401 1379.02 313.952 1380.49 300.194Z" fill="#8B7CF6"/> 10 + <path d="M997.534 241.726C997.543 199.867 997.506 159.293 997.578 118.718C997.616 97.7314 997.704 97.7313 1019.2 97.6735C1021.76 97.6666 1024.33 97.5592 1026.88 97.6798C1034.95 98.0608 1043.7 95.4483 1050.94 99.1391C1059.55 103.531 1053 113.382 1056.01 120.162C1056.49 121.233 1057.49 122.074 1058.77 123.684C1065.73 117.854 1070.68 110.208 1078.49 105.681C1085.57 101.576 1092.85 98.1965 1100.75 96.1119C1128.92 88.672 1134.78 92.9907 1134.78 121.193C1134.78 150.407 1134.78 150.407 1106.74 152.768C1079.26 155.082 1060.81 178.942 1060.35 213.694C1059.96 243.16 1059.76 272.646 1060.47 302.098C1060.76 313.97 1056.82 318.14 1045.1 317.231C1034.06 316.375 1022.88 316.499 1011.81 317.134C1001.35 317.734 997.194 313.884 997.463 303.227C997.968 283.167 997.561 263.083 997.534 241.726Z" fill="#8B7CF6"/> 11 + <path d="M327.205 196.445C346.392 227.48 344.44 257.209 326.632 287.113C321.466 295.787 320.188 306.488 311.743 318.385C281.24 244.548 251.7 173.041 221.305 99.4664C243.979 96.4046 263.826 97.3932 283.594 98.4589C289.584 98.7818 290.634 105.289 292.507 109.94C303.995 138.451 315.382 167.004 327.205 196.445Z" fill="#8B7CF6"/> 12 + </svg>
+8
web/svelte.config.js
··· 1 + import adapter from '@sveltejs/adapter-node'; 2 + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 + 4 + /** @type {import('@sveltejs/kit').Config} */ 5 + export default { 6 + preprocess: vitePreprocess(), 7 + kit: { adapter: adapter() }, 8 + };
+14
web/tsconfig.json
··· 1 + { 2 + "extends": "./.svelte-kit/tsconfig.json", 3 + "compilerOptions": { 4 + "allowJs": true, 5 + "checkJs": true, 6 + "esModuleInterop": true, 7 + "forceConsistentCasingInFileNames": true, 8 + "resolveJsonModule": true, 9 + "skipLibCheck": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "moduleResolution": "bundler" 13 + } 14 + }
+11
web/vite.config.ts
··· 1 + import { sveltekit } from '@sveltejs/kit/vite'; 2 + import Icons from 'unplugin-icons/vite'; 3 + import { defineConfig } from 'vite'; 4 + 5 + export default defineConfig({ 6 + plugins: [sveltekit(), Icons({ compiler: 'svelte' })], 7 + // Lightning CSS handles the @layer cascade in app.css. No browser targets set 8 + // ponytail: internal dashboard, modern browsers only; add targets if old browsers matter. 9 + css: { transformer: 'lightningcss' }, 10 + build: { cssMinify: 'lightningcss' }, 11 + });