Skip to content

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_relevance tag. 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_relevance via 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_ii has no pinned options — the mapper can't work without the vocabulary.
  • Warn only on deal_stage label 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_ii tags, 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 StrategicProfileresearch_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.py stops 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?"