✦ Nostrautica docs

Matching benchmark: model × prompt × batch size

Setup. 20 synthetic personas (cypherpunk conference, real ai_profile shape from profile.ts) with hidden ground truth: 10 planted STRONG pairs, 13 MEDIUM, rest weak (benchmarks/matching/gold-pairs.json, never shown to models). Scoring runs on Venice (same request shape/quirks as providers/venice.ts). Primary axis is batched scoring (one call = 1 target + K candidates returning per-candidate {similarity, complementarity, score, reasoning_for_target}) with pairwise (K=1, exact production prompt P0) as the reference. Metrics: recall of gold-strong pairs in each persona's top-3/top-1, strong-vs-weak score separation, blind-judged reasoning quality (1–5, user-facing-host rubric), position bias, format failures, latency, cost. Harness + raw data: benchmarks/matching/.

Results (headline rows; full table via node evaluate.mjs)

Model Prompt K recall@1 recall@3 sep S–W judge fail p50 lat $/100 attendees*
deepseek-v4-flash BP3 10 (full 190) 0.75 0.90 0.59 4.53 0 9.5 s $0.22
gemini-3-flash-preview BP3 10 (full) 0.70 0.90 0.56 4.33 0 5.6 s $1.67
zai-org-glm-5-2 BP3 10 (full) 0.70 0.90 0.59 4.63 0 10.6 s $2.64
zai-org-glm-5-2 (production) P0 1 (pairwise) 0.75 1.00 0.34 4.00 0 9.0 s $9.90
zai-org-glm-4.7-flash BP3 10 (subset) 0.80 0.90 0.48 – 0 8.6 s $0.32
deepseek-v4-flash BP0 (naive batch) 10 (subset) 0.80 1.00 0.48 2.97 0 10.0 s $0.22
deepseek-v4-pro P0/BP0 (partial) – 0.80 1.00 0.40 – 0 16.2 s –
z-ai-glm-5-turbo P0 1 (pairwise) – – – – 116 11.1 s –

* prefiltered ≈2k pairs/100 attendees ⇒ 4k directional scorings = 400 K=10 calls (pairwise: 2k calls). Judge = blind 15-sample mean. Ruled out: deepseek-v4-pro on latency (maintainer call: ~16 s p50 / 23 s p95; partial subset numbers above for context). z-ai-glm-5-turbo disqualified for pairwise P0 (116/120 format failures under strict json_schema); worked batched but slow (13–25 s p50).

Later rounds live in MODEL-BAKEOFF.md (from 2026-08-26). The procedure below was run by hand; it is now frozen in benchmarks/matching/bakeoff.mjs so that a model benchmarked next month is measured against exactly what these rows were. This document stays as the record of how the deployed prompt and model were chosen.

Deprecation follow-up: deepseek-v4-flash → deepseek-v4-flash-0731 (2026-08-04)

Venice deprecated the winning id: GET /models carries deprecation: {date: 2026-08-14, autoRemap: false, replacementModelId: "deepseek-v4-flash-0731"}. autoRemap: false means nothing silently redirects, on 2026-08-14 the old id simply stops resolving, which for this coordinator is a startup model-verification failure, not a degraded match. Production moved to 0731 before that date.

Re-ran BP3 / K=10 / eval subset on both ids, two seeds each (paired, so the comparison isn't reading seed noise as a regression):

id recall@1 recall@3 sep S–W strong weak posBias fail p50 lat
deepseek-v4-flash (0423) 0.80 / 0.75 0.95 / 0.95 0.65 / 0.64 0.89 / 0.88 0.25 / 0.24 0.04 / 0.10 0 16.1 s / 6.6 s
deepseek-v4-flash-0731 0.75 / 0.75 0.95 / 0.90 0.62 / 0.59 0.91 / 0.89 0.29 / 0.31 0.09 / 0.19 0 11.2 s / 6.2 s

Like-for-like within noise on the metrics that decide what attendees see. The one consistent (both seeds) difference is weak pairs scoring ~5 points higher, which narrows the strong–weak margin without reordering: ordering-above-weak stays 0.98–0.99 and matches are selected by rank (top_k), not by an absolute score floor. Zero format failures under strict json_schema across 84 calls.

Two things changed besides the id:

What actually moved quality

Negative result: the flat top is not a prompt problem (2026-09-12)

Production's Plan B event (39 attendees with profile content, 1,482 directed scores) rendered "Strong match" on essentially every match an attendee opened. The first suspicion was score compression — the defect this document's rubric anchors were written to fix — so the scores were pulled and looked at directly.

They were not compressed. Across all 1,482 pairs only 28% clear 0.80, spread smoothly over 0.05–0.95, mean 0.60. The scorer uses its range. What produced the symptom was the badge: an ABSOLUTE threshold applied to a list already truncated to the top top_k by rank. Of the top 20 each attendee is shown, 51% clear 0.80, and 30 of 39 saw an all-strong top five. That is a UI bug, fixed in packages/app/src/lib/events/confidence.ts, not a scoring one.

What survived the fix is a real property of the data: the top of a list is genuinely flat. Scores land on multiples of 0.05, so 1,482 of them take 19 distinct values; 19 of 39 attendees had a tied #1, and an average top five holds only 2.46 distinct scores. Five attendees have six or more pairs tied at 0.95.

So four prompt variants were run against that real roster, two seeds each, to see whether the flatness was something the prompt was causing. Each is the deployed BP3 plus exactly one inserted block:

variant strong% all-strong top-5 sd distinct tau dirGap meta%
V0 deployed prompt 23 17/39 0.230 18 0.71 0.159 1.9
V1 discount the event baseline 23 16/39 0.230 18 0.68 0.163 4.1
V2 explicit per-batch quota 25 16/39 0.235 30 0.72 0.167 3.1
V3 ask for two decimals 24 18/39 0.228 32 0.69 0.152 2.0
V4 V1 + V2 22 15/39 0.231 19 0.69 0.165 3.4

tau = Kendall between the two seeds; dirGap = mean |A→B − B→A|; meta% = reasonings containing analytical framing. Metrics were chosen so a prompt that merely adds NOISE cannot win: spread must rise while tau and dirGap hold.

Nothing moved. strong% 22–25, all-strong top fives 15–18 of 39, sd 0.228–0.235. At n=39 with two seeds those are indistinguishable.

Three findings worth keeping:

The conclusion is mechanical, not linguistic. scoreBatch judges each candidate independently — deliberately, so scores stay comparable across the four batches a 38-candidate target needs — and nothing in that design ever asks "of these ten, which is best?". Three different ways of asking the scorer to spread itself failed, and the granularity result rules out the reading that it wanted to and could not. When it says ten people are 0.95, that is its answer.

Caveats: one event, one language (cs), one model, n=39. The harness reproduces production's DISTRIBUTION (strong% 20 vs 23, sd 0.227 vs 0.230, distinct 19 vs 18) but not its second-order structure (rater share of variance 38% vs 20%, hub Gini 0.52 vs 0.65) — production scored this roster incrementally over eight days as people joined, so every pair was judged beside whatever batch peers were pending at the time, and 6% of its rows predate an edit to one of the two profiles. Read the table as variant-vs-V0; variant-vs-production is not a claim it can support.

Cost: $2.24 across 1,560 calls, 0 unparsed and 0 failed. The roster is real attendees, so the fixture and harness stay local — see benchmarks/matching/private/README.md, which is gitignored.

Follow-up: a rerank pass, and what it exposed about the scorer (2026-09-13)

The negative result above pointed at the mechanism rather than the wording, so the obvious next thing was tested: take each target's top-N by score and ask, in one call, for a strict order over them — the question independent scoring never asks. 39 targets, two runs per configuration, the shortlist presented in a DIFFERENT shuffled order each run. The reranker emits an order and nothing else; after V1's leak there is no reason to give a second prompt a prose channel.

The only honest question about the output is whether it is reproducible, and the baseline for that is not zero — it is how well the scorer agrees with itself on exactly the same shortlists. That comparison is the finding:

ordering the top 5 cross-seed Kendall tau same #1 in both runs
current scorer 0.215 11/39 (28%)
listwise rerank 0.463 19/38 (50%)
ordering the top 10
current scorer 0.242 9/39 (23%)
listwise rerank 0.289 16/39 (41%)

The scorer's top-of-list order is barely reproducible. Re-run matching with the candidates shuffled into different batches and the attendee's #1 match changes about three times in four. That is not a reranking problem, it is a property of what ships today, and it was invisible until something was measured against it: the sweep's headline tau of 0.71 runs over all 38 candidates, where Kendall skips tied pairs and most comparisons are between an obvious 0.9 and an obvious 0.2. Restricted to the five people an attendee actually looks at — near-identical scores, which is the hard case — it falls to 0.215.

Reranking roughly doubles that, and only at a short list. At N=10 the returned order correlates with the order the model was SHOWN (0.313) about as much as with itself across runs (0.289): ten near-identical profiles is past what it can hold, and it starts echoing the list back. At N=5 content wins over position (0.463 vs 0.353).

So a rerank over the top ~5 is worth building; over the top 10 it is not. Neither is a fix for the flat top — the reranker disagrees with the scorer's order about as much as it disagrees with itself (tauScore 0.27–0.29), so it is not resolving the ties so much as replacing one unstable order with a less unstable one. Before implementing, the thing to settle is what "better" means here, because no metric on this page can distinguish a more reproducible order from a more USEFUL one. That needs the human labels evaluate.mjs --labels was written to collect.

Cost: $0.06 across 156 calls.

A decision model instead of a chat model: Jev (2026-09-19)

Venice now lists jev-latest (TypeSafe AI's "System One" model, type: decision) behind its own endpoint, POST /decisions. It takes a state and a map of typed questions and returns probabilities: a score question over an ordered rubric comes back as a probability-weighted level with the full distribution. No prose. Input tokens only are billed ($0.042/Mtok, output $0), the state is billed once per request however many questions ride on it, and a call returns in under a second. The API notes are in the operator's server-documentation/experiments/jev.md; this section is what it did on the fixture. Harness: benchmarks/matching/jev-run.mjs, results score through the unchanged evaluate.mjs.

BP3 cannot be used as a prompt, so its five score anchors became a five-level rubric (score / 4 → 0..1), with similarity and complementarity rubrics and a noul (yes/no probability) "should the target make a point of meeting this candidate" on the same request. Two shapes: pair, one request per directed pair with the EVENT/TARGET/CANDIDATE text block BP3 sees (380 requests); batch K=10, a JSON state {event, target, candidates[]} with a score and noul question per candidate (40 requests). The deployed model was re-run with a second seed today so stability could be compared like for like.

scorer shape recall@1 recall@3 sep S−W posBias p50 $ (380 edges)
deepseek-v4-flash-0731 BP3 K=10 seed 1 / 2 0.65 / 0.70 0.85 / 0.85 0.55 / 0.51 0.11 / 0.08 28.9 s / 18.1 s $0.0267
jev, score rubric pair seed 1 / 2 0.70 / 0.70 0.90 / 0.90 0.52 / 0.52 0.00 0.56 s $0.0162
jev, score rubric K=10 seed 1 / 2 0.70 / 0.75 0.85 / 0.85 0.51 / 0.52 0.08 / 0.03 0.73 s $0.0074
jev, score rubric K=5 seed 1 0.75 0.85 0.51 0.00 0.59 s $0.0084
jev, noul pair / K=10 0.75 / 0.70–0.75 0.85 / 0.85–0.90 0.46 / 0.54 0.00 same calls same calls

Eval subset: jev K=10 recall@1 0.75, recall@3 0.95 on both seeds, the same as 0731's best subset row, at $0.0025 against $0.0098.

On ranking quality it is a tie. Every difference above is one or two targets out of twenty and flips between seeds. Both scorers miss the same three gold pairs (Yusuf↔Elena, Aleksy↔Priya, Mara↔Nadia) and put about the same number of unplanned pairs into a top three (jev 30–33 of 60, DeepSeek 35–36).

On stability it is not. The rerank study above found the deployed scorer's top-of-list order barely reproduces; the same measurement on this fixture:

two runs, same configuration top-5 τ top-10 τ same #1 mean |Δscore| per edge tied #1s distinct values dirGap
deepseek-v4-flash-0731 K=10 0.571 0.506 16/20 0.109 (max 0.50) 5 and 8 of 20 21 0.128–0.149
jev K=10 0.626 0.719 16/20 – 0 81–82 0.085–0.094
jev pair 0.876 0.905 19/20 0.012 (max 0.08) 0 86–88 0.083

DeepSeek snaps to multiples of 0.05; a DeepSeek top five holds 3.4–3.8 distinct scores, jev's 4.95–5.00. This is the flat-top finding of 2026-09-12 addressed at the source rather than in the UI: the ties are not there to begin with.

Two caveats the numbers carry. Jev pair and jev K=10 agree with each other at τ 0.65, about as well as K=10 agrees with itself across seeds: the other candidates in a batched state leak into a score even though every question is evaluated in isolation, so this batch shape is cheaper and roughly a third less reproducible. The leak is specific to several items sharing a state: a sibling project measured fifteen questions over one item against one question per request at Spearman 0.999 the same day. The shape to try next is therefore the anchor alone in the state and each candidate's text inside its own question. And score vs noul is a wash on rank (noul slightly better at #1, worse on separation); keep score for its re-thresholdable distribution and add noul free on the same request.

Operationally: 1,340 paid requests, zero non-200 after retry, zero missing answers. Venice allows 100 decisions/min per key and locks the key for 30 s after more than 50 non-success responses, so the runner paces itself rather than retrying; the long p95s in the logs are backoff from two other sessions sharing the key during the sweep. Position bias is 0.00–0.08 (0.15 on one 21-call subset seed).

What it does not give you. No reasoning_for_target: a chat model still writes the sentence the attendee reads. Same anonymized tier as today, beta, and this is the synthetic fixture only: whether the continuous scores stay spread on a single-topic Czech roster, where the chat model rated the whole room 0.95, is exactly what private/ exists to ask and has not been asked.

Cost per 100 attendees (~4,000 directed scorings): jev K=10 ≈ $0.07, jev pair ≈ $0.17, deployed path ≈ $0.28 including prose. A hybrid, jev scoring everything and the chat model writing reasoning only for the ~2,000 edges shown (and, seeing the shortlist together, doing the top-5 rerank), lands near $0.21. The larger win is wall-clock: 400 batched requests clear in ~4 minutes against ~30 for the same DeepSeek calls.

The same day on the real roster

The synthetic fixture cannot show the flat top, so jev was then run on the Plan B roster through the private harness (benchmarks/matching/private/jev-run.mjs; the rows stay on the machine, only these aggregates leave it). Shape: target alone in the state, each candidate's profile inside its own score and noul question, all 38 in one request or ten per request. 39 requests, 27 seconds and $0.05 for a full run. The deployed prompt on deepseek-v4-flash-0731 was re-run at two seeds the same day as the reference, beside the live scores.

Plan B, 1,482 directed scores tied #1 distinct in a top 5 distinct values cross-seed top-5 τ same #1 across seeds whole-list τ dirGap
production (live) 19/39 2.46 19 – – – 0.114
deepseek 0731, same day 18 and 23 /39 2.6–2.8 18–23 0.34 14/39 0.73 0.159
jev, all candidates per request 0/39 4.9 348 0.89 36/39 0.96 0.098
jev, ten per request 0/39 4.9 347 0.86 37/39 0.96 0.098

Re-run the deployed scorer and 25 of 39 attendees get a different #1; re-run jev and 3 do. Jev agrees with production on the #1 for 12/39 (top-5 Jaccard 0.27), which is the same as the deployed model re-run today agrees with its own live output (11/39, 0.28), so the disagreement is the chat scorer's noise, not a jev divergence; whole-list Spearman against production is 0.60 for both. Two things to carry into any wiring change: jev's scale is lower (3% of pairs ≥ 0.8 against 23–28%, no all-Strong top fives against 15/39), so the confidence.ts banding needs recalibrating; and jev hands its most popular attendee 13 first places against production's 8, which only a human can judge. The private directory holds a blind sheet (labels-blind-top3.json) with each attendee's top three from jev, DeepSeek and production in shuffled columns for exactly that. Cost: $0.21 of jev across 390 requests, $0.35 of DeepSeek.

Recommendation. Build the hybrid: jev scores every pair (target in state, candidates in questions), the chat model writes reasoning for the shown edges and reranks the top five while it has them together. Ship the badge threshold change with it. Read the blind sheet before deciding whose picks are better; reproducible is not the same as right. Nothing in the coordinator was changed for this. Cost: ≈ $0.075 of jev across 1,340 requests, plus $0.027 for the second DeepSeek seed.

Recommendation

Adoption: set models.match in coordinator config to the chosen model id; replace PAIR_SYSTEM_PROMPT in packages/coordinator/src/matching/scoring.ts with the appendix prompt (for pairwise adoption, keep the two-reasonings paragraph but replace its style instructions with the host-voice block). Event context is already wired: scorePair receives EventContextForScoring (31923 title/summary/hashtags) via matcher orchestration, keep passing it; batched scoring would group selectPairsToScore output by target and send K=10 candidate blocks per call.

You are a conference matchmaker for the event described below. You are given ONE target attendee
and a numbered list of candidate attendees. For EACH candidate, judge how valuable it would be
for the TARGET to meet them, considering what THIS event is for.

Score three fields, each a DECIMAL between 0.0 and 1.0 (never 0-10 or 0-100):
 • similarity: shared interests, background, or goals.
 • complementarity: how much their skills/roles COMPLETE each other for this event — one has what
   the other needs (a founder needing a Rust dev + a Rust dev wanting a mission; a drummer + a
   bassist; powerful-but-unusable tech + a designer). This is the most important signal.
 • score: overall value of the meeting. A meeting is high-value when one person's SEEKS is met by
   the other's OFFERS/skills (in either direction). Reward that fit heavily.

Score anchors for `score`: 0.9-1.0 = a near-perfect mutual fit (each solves the other's stated need);
 0.7-0.85 = strong one-directional or clearly useful fit; 0.4-0.6 = plausible, some overlap but no
 sharp need met; 0.15-0.35 = weak, only vague topical overlap; 0.0-0.1 = no real reason to meet.

Scoring rules:
 • Score each candidate INDEPENDENTLY on its own merits. Do not let an early strong candidate inflate
   later ones, or let a strong batch drag up a weak candidate. Use the FULL range — most candidates in
   a batch should NOT score high.
 • Ground every judgement in the ACTUAL profile text. Never invent skills, goals, or facts.

reasoning_for_target — THIS TEXT IS SHOWN DIRECTLY TO THE TARGET ATTENDEE. Write 1-2 sentences in
the voice of a good host introducing them to the candidate:
 • Second person, direct: "You should grab Elena — ...", "Ask him about ...".
 • Name a CONCRETE thing to talk about or do together, drawn from both people's actual details.
 • ABSOLUTELY NO analytical framing: never say "this pair", "based on their profiles", "high
   complementarity", "scores", "match", or explain why a rating was given. No hedging boilerplate.
Example of GOOD: "You've been hunting for a bassist — Sunny plays bass, she's new in town and dead
serious about joining a band; ask her what she'd want your first setlist to sound like."
Example of BAD (never do this): "This pair has high complementarity because both are musicians
seeking bandmates, resulting in a strong match score."

Return one entry per candidate, using the candidate's number as `index`. Score EVERY candidate exactly once.

Benchmark cost: ≈$3.4 of Venice API across ~2,377 calls (2.9M prompt / 1.0M completion tokens). Reproduce: benchmarks/matching/README.md.