u2v-lp-match¶
🔎 Retriever · dispatcher-routed · read-only
u2v-lp-match answers the question that comes up before every LP conversation: which of our recent
dealflow startups should we actually show this LP? Give it a prospective LP (a company name or link)
or a sector, and it web-researches a strategic profile, maps that onto U2V's sector taxonomy, pulls
the deals added recently, and returns a ranked shortlist — each pick with a one-line reason. This
page goes deep on how it does that. Use the table of contents on the right to jump around.
At a glance¶
flowchart TD
A["--company "Kion" OR --sector "warehouse logistics""] --> B{Which input?}
B -->|company| C["Web-research the LP's strategy<br/>(fold in a known thesis if we have one)"]
B -->|sector| C2["Reason about the sector landscape<br/>(incl. adjacent enabling tech)"]
C --> D["Map the profile onto U2V Sector-II:<br/>main (core) vs adjacent (enabling)"]
C2 --> D
D --> E["Fetch dealflow added in the window<br/>(default 8 weeks, any stage) in those sectors"]
E --> F["Enrich each candidate with its U2V fit note"]
F --> G["Opus ranks a shortlist —<br/>one-line rationale each"]
G --> H["Return the shortlist as a message<br/>(no files)"]
The spine is run_match in
cli.py:
research → map → fetch → enrich → rank → reply. Unlike dealflow it isn't a folder of rubric files — it's
a compact sequence of three model calls (research, map, rank) with deterministic glue between them.
It's live and running through the dispatcher, and offline-tested (25 tests, including golden
end-to-end runs for both company and sector modes). Every heavy dependency (httpx, anthropic) is
imported lazily inside the step that needs it, which is what lets the whole suite run offline with no
keys.
The mental model: discovery, not rendering¶
lp-match is the discovery counterpart to u2v-lp-share, and holding the two side by
side is the fastest way to understand it:
- lp-share renders what's already decided. A deal appears for an LP only if it's been flagged with
that LP's
lp_relevancetag. lp-share reads those flags and turns them into documents. - lp-match runs before any flag exists. The prospective LP is brand new — nothing is tagged for
them yet. lp-match's whole job is to propose which recent deals are worth showing, so a human can
then flag them (setting
lp_relevancevia dealflow or by hand). It writes nothing back and produces no files — the answer is the reply message.
That's the one idea to keep: lp-match is a read-only suggestion engine. It reads the dealflow list, thinks in three model calls, and hands back a shortlist. Nothing it does changes Attio.
The two inputs — a company or a sector¶
You can drive lp-match two ways, and both funnel into the same internal shape — a StrategicProfile
(company name, a summary, business lines, ~10 strategic interests, keywords, a confidence flag). The
profile is what everything downstream reasons about, so the two input paths converge immediately. If you
pass both --company and --sector, the company wins.
Company mode (research.research_company) is one Sonnet call with web search. It researches the
named LP or URL and extracts the themes where seeing early-stage startups would matter to them —
grounded in what the web says, not invented. Before that call runs, there's a shortcut worth knowing:
if the name matches a known LP on the Soft LPs U2V roster (the same dealflow_sharing = "Yes"
roster lp-share uses), that LP's hand-authored strategic_interests thesis is folded into the
research prompt as authoritative context (_known_thesis in cli.py). So an LP we already understand
gets researched from our own thesis rather than from scratch. It's best-effort — if the roster can't
be read, the tool just researches from the web.
Sector mode (research.research_sector) is deliberately not a literal echo of the phrase you
typed. It's a real reasoning call that acts like a deep-tech analyst: it thinks about where the
innovation actually sits in that space — the sub-domains, the trends, and crucially the adjacent
enabling technologies (robotics, sensors, autonomy, materials, energy). That gives the next stage a
broad surface to map against. If the model errors or comes back empty, it degrades gracefully to
sector_profile(), a deterministic single-phrase profile — the run never crashes, it just gets less
clever.
Both paths cap interests at 10 (_MAX_TOPICS) — a deliberate "focused, not exhaustive" invariant,
so a rambling profile can't drown out the signal downstream.
Mapping onto the sector taxonomy — main vs adjacent¶
A StrategicProfile is prose; the dealflow list is tagged with a fixed Sector-II vocabulary. Stage
two (sectors.map_sectors, one Sonnet call) bridges them — it reads the profile and returns the
sub-sectors it maps onto, split into two groups:
- main — the LP's core (capped at
max_main, default 2); - adjacent — neighbouring or enabling sub-sectors.
The vocabulary is injected into the call as a strict JSON-schema enum on both groups, so the model
can only ever return real sub-sector titles — and the result is then re-validated in code (_clean:
drop anything not in the enum, dedupe, cap main, trim the union). The design that follows is recall
first, precision last: lp-match fetches the union of main + adjacent, so an oddly-tagged but
genuinely relevant deal isn't missed — then hands the main/adjacent split to the ranker as a signal, so
an adjacent-only startup has to be genuinely on-point to earn a slot.
Why the taxonomy is pinned, not read live. The 37 Sector-II sub-sectors live in
reference/attio_read_map.yaml,
not fetched from Attio at runtime — because the live options API serves a stale, truncated set
from an old workspace. The pinned list (original typos and all) matches how deals are actually tagged,
which is the only thing that matters for filtering. This is load-bearing enough that the test suite has
an explicit guard: conftest.py deliberately has no /options handler, so any code that tried to read
sector options live would fail the tests loudly.
Selecting candidates — recency is the only gate¶
With the sectors chosen, attio.fetch_candidate_deals pulls the candidates. Inclusion is exactly two
conditions — added within the window, and tagged in one of the mapped sectors — computed
client-side, over any funnel stage:
# attio.py — filter the cheap entry fields FIRST; resolve the company only for survivors
for entry in self._iter_list_entries(rm.list_api_slug):
created = parse_timestamp(entry.get("created_at"))
if since is not None and (created is None or created < since):
continue
ev = entry.get("entry_values") or {}
if wanted:
titles = {t.strip() for t in _select_titles(ev.get(sector_slug))}
if not (wanted & titles):
continue
company = self._company_values(rm.parent_object, entry.get("parent_record_id"), ...)
out.append((entry, company))
The recency cutoff (since = now − window_weeks, default 8 weeks) is the only hard filter.
There is deliberately no score threshold and no lp_relevance filter — this runs upstream of
flagging, so there are no flags yet, and a Passed deal is still in scope (U2V holds a first-hand
reference to it). Stage and engagement don't decide inclusion; they only become tiebreakers at ranking.
Note the ordering in the loop: the cheap entry fields (created_at, then sector) are checked first, and
the parent company record is resolved only for the survivors — so a run touches very few company
records even though the list has 700+ entries.
Ranking — the one-shot Opus call¶
Every surviving candidate is first enriched with its U2V fit note (fetch_assessment_note) — matched
by the stable title stem ^u2v\W*fit / ^u2v\W*score, most-recent wins, the same title-drift trick
lp-share uses. Because the candidate set is small, this handful of note reads is cheap.
Then the whole set goes into one call to Opus (claude-opus-4-8, rank.rank_candidates). Each
candidate is rendered as a compact view — an integer ref, the company, its sub-sectors, stage,
added-date, description, and the fit note (capped at 1200 chars). The prompt encodes the judgment rules:
relevance is primary; the ranker is main-aware (an adjacent-only startup must be specifically
on-point, so generic horizontal companies can't crowd out the genuinely relevant ones); stage and
engagement are tiebreakers only; and, importantly, quality over quantity — return fewer than
shortlist_size for a niche space rather than padding a weak list. Each rationale is one sentence.
Two defenses stop the model inventing things. Sub-sectors are enum-constrained (as in the mapper), and
candidates are keyed by integer ref, never by name — so a pick maps back unambiguously and any ref
the model didn't get from us is simply dropped:
# rank.py — a pick only survives if its ref is a real candidate we handed in
for p in raw.get("picks") or []:
ref = p.get("ref")
if not isinstance(ref, int) or ref not in by_ref or ref in seen:
continue
seen.add(ref)
picks.append(Pick(deal=by_ref[ref], rationale=..., relevance=p.get("relevance") or "medium"))
if len(picks) >= shortlist_size:
break
A hallucinated company literally cannot appear in the shortlist — it has no ref in by_ref.
The output — a message, not a file¶
lp-match returns the standard dispatcher envelope, and files is always empty — there are no
artifacts in v1:
# cli.py — the shortlist lives entirely in `message`
print(json.dumps({"ok": True, "message": message, "files": []}))
report.build_message renders the MatchResult into a dispatcher-safe markdown body: a short intro,
then one bullet per pick, then a "what I searched" footer that names the main sub-sectors (and, apart,
the adjacent ones). Each bullet is a single line —
- **Acme Robotics** — Due Diligence · added Jun 2026 · acme.com — Autonomous forklifts fit Kion's automation push.
The markdown is restricted to what the dispatcher's renderer supports — paragraphs,
single-line bullets, bold/italic, and bare domains, not links. The empty paths are handled honestly:
if no sector could be confidently mapped it says so and asks for a specific space; if sectors mapped but
nothing landed in the window it says that and suggests widening the window; a low-confidence company
research adds a caveat. And a failure mid-run still emits a valid envelope — _fail returns
{"ok": false, ...} rather than a stack trace, because a clean envelope is what the dispatcher relays.
Read map & preflight¶
As in lp-share, no Attio slug or sector value is hardcoded in src/ — every read goes through
reference/attio_read_map.yaml,
loaded into a typed ReadMap (fieldmap.py). Before anything runs, attio.preflight() reconciles that
map against live Attio, and its fail-loud/warn split is deliberate:
- Hard error if
u2v_sector_iihas no pinned options — the mapper can't work without the vocabulary. - Warn only on
deal_stagelabel drift (stage is used for annotation, not filtering) and on an unreadable roster (that only powers the known-thesis shortcut).
And, as above, preflight pointedly does not read the sector options live — the pinned vocabulary is the source of truth.
Limitations¶
Be honest about where this tool sits today: it's only half of the way there. It's a genuinely useful first pass, but the shortlist it proposes is sometimes not perfectly square on an LP's real strategic interest — a pick can be adjacent-but-not-quite, or it can miss a deal you'd have thought of. That's the working thesis of this whole system in miniature: as of today, we are still the best decision-makers on the team. lp-match narrows the field and does the legwork; the judgement call is still yours.
- The final selection is only as good as the data in Attio. This is the one that matters most.
Ranking looks at exactly what Attio holds — the deal's description, its
u2v_sector_iitags, and its U2V fit note — and nothing else. A deal with no description or no fit note gives the ranker almost nothing to reason about, so it will be assessed poorly however good the company actually is. There's no hidden knowledge to fall back on. This is the sharpest argument for keeping Attio data quality high: a clean, well-tagged, fit-noted record is the only thing this tool sees when it ranks opportunities. - Recency is a hard gate. A genuinely perfect deal added before the window simply won't surface.
Widen it per run with
--window-weeks, but there's no "all-time" mode by design — this is a what's-new-worth-showing tool. - A mis-tagged deal can be missed. Inclusion hinges on
u2v_sector_ii; the adjacent-breadth net mitigates a bad tag, but doesn't eliminate it.
The selection algorithm is not sacred
The research → map → rank pipeline here is a way to find the best deals for an LP — not necessarily the way. It's deliberately simple and pinned to what Attio can tell it. If someone comes up with a better selection algorithm — a sharper relevance signal, a smarter candidate net, a different ranking model — please feel free to adapt it. This is the front half of a workflow that deserves to keep getting better.
Where things live¶
| Path | Role |
|---|---|
src/u2v_lp_match/cli.py |
Entry point + composition root; the run_match spine; known-thesis lookup; the --json envelope + fail-loud _fail |
src/u2v_lp_match/research.py |
Builds the StrategicProfile — research_company (web) and research_sector (reasoning + echo fallback) |
src/u2v_lp_match/sectors.py |
map_sectors: profile → main/adjacent Sector-II, enum-constrained + code-revalidated |
src/u2v_lp_match/attio.py |
Read-only Attio client + preflight; fetch_candidate_deals (recency ∩ sector); fit-note + roster reads |
src/u2v_lp_match/rank.py |
rank_candidates: the one-shot Opus rank → best-first picks + rationales |
src/u2v_lp_match/llm.py |
The single Anthropic door — forced tool-call for schema-valid output; handles web-search vs forced-tool exclusivity |
src/u2v_lp_match/mapping.py |
Raw Attio entry + company values → a typed CandidateDeal |
src/u2v_lp_match/models.py |
Pydantic contracts (StrategicProfile, CandidateDeal, SectorSelection, Pick, MatchResult) |
src/u2v_lp_match/report.py |
build_message / headline: MatchResult → dispatcher-safe markdown; honest empty paths |
src/u2v_lp_match/fieldmap.py |
Loads the read map into a typed ReadMap; compiles the fit-note title regexes |
src/u2v_lp_match/config.py |
Pydantic loader/validator for config.yaml |
reference/attio_read_map.yaml |
The Attio slug map and the pinned sector taxonomy — read-only |
config.yaml |
U2V-team-editable knobs (see below) |
Knobs a U2V team member can safely change in config.yaml: selection.window_weeks (how far back
to look) and selection.shortlist_size; the sector breadth bounds sectors.min / sectors.max and
sectors.max_main (how many count as "core"); which model runs each of the three steps (models.*);
and the web-search toggles for company vs sector research. The sector vocabulary and every Attio slug
live in the read map; the LP roster and each LP's thesis live in Attio.
How to run it¶
cd u2v-lp-match
PYTHONPATH=src .venv/bin/python -m u2v_lp_match.cli --company "Kion" --json
PYTHONPATH=src .venv/bin/python -m u2v_lp_match.cli --sector "warehouse logistics" --window-weeks 12
PYTHONPATH=src .venv/bin/python -m u2v_lp_match.cli --list-sectors # print the canonical Sector-II vocab
# --shortlist-size N to change how many it proposes
In production it's the dispatcher's lp_match workflow: an email like "We're speaking to Kion as a
potential LP (kion.com) — what could we show them?" routes here. The classifier fills exactly one of
lp_company / sector_query from the email, and the shortlist comes back in the reply body (no
attachments).
Go deeper
Open the repo in Claude Code and ask, for example:
- "Why does lp-match use Sonnet for research and mapping but Opus for the ranking call?"
- "Show me how
rank.pystops the model returning a company that wasn't in the candidate set." - "Why is the Sector-II vocabulary pinned in the read map instead of read live from Attio?"