Skip to content

Break the research loop: an untitled name is still a fact worth citing - #40

Merged
ralyodio merged 1 commit into
mainfrom
feat/break-research-loop
Aug 17, 2026
Merged

ralyodio merged 1 commit into
mainfrom
feat/break-research-loop

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

The root cause was one condition

The queue held 73 pending cards, all refresh_research, none with a signal. That reads as a broken composer. It was a closed cycle.

storeSiteSignal returned early when a crawled person had no job title. The correlation across all 208 people in production is exact:

has signal no signal
has title 131 0
no title 1 76

Every one of the 73 pending cards was an untitled person. No signal → generateRecommendation refuses outbound (outreach with no reason is what the product exists to avoid) → falls back to refresh_research → which nothing executed. The card that existed because there was nothing to say could never produce anything to say.

The gate was never a policy decision. "Listed as Office Manager on the company website" and "named on the company website" are equally unrelated to buying intent — the title was load-bearing only because the summary string needed it to be grammatical.

What changed

An untitled listing is a signal, at relevance 0.35 against 0.5 for a stated role. Weight is decay × confidence × relevance against a 0.15 floor, so 0.315 fresh and 0.205 at thirty days will trigger, and by sixty days it stops — the right shape for a thinner pretext.

The crawl follows the pages that name people. A homepage describes the company, links to /team, and names nobody. Now up to three same-origin links whose path or anchor text looks like a team/about/contact page. Bounded deliberately: a fixed shortlist rather than a spider, robots.txt fetched once and reused, and the model asked at most once for the whole site after the cheap deterministic reads are merged — so four pages cost four fetches, and the expensive pass runs only if all four together named nobody. Merging prefers the titled record.

Approving research now causes research. refresh_research had no executor anywhere; the runner knows four job kinds and this was not one. It now queues a crawl_site for the company's own domain. A person with no company on file says so instead of queueing a job that cannot run.

Recommendations are reconciled, not just inserted. Re-crawling would otherwise stack a second pending card on the same person. Only pending internal actions are superseded — an outbound card may carry a drafted message and a half-made human decision, and replacing that silently would discard real work.

A signal without a channel keeps the person visible. Reachable only on website means no permitted engagement action, and returning nothing made the lead vanish entirely. It now falls back to the passive set, which is the honest description: we know something, and have nowhere to use it.

Migration 0015

Backfills the signal the fixed code would have written, for the 75 already-crawled people who have none. Dry-run against production: 75 rows, 30 of those people reachable via a company inbox.

Dated to the person's created_at rather than now, so decay treats the evidence as the age it really is instead of resetting the clock on something nobody re-checked.

It writes no recommendations, so it sends nothing. Autopilot's candidate query only ever selects pending action = 'send_email' rows; the 73 are refresh_research. Cards only become sendable when a crawl regenerates them — which is a separate, deliberate step.

Blast radius, stated plainly

All 73 cards sit in trusted_automation (autopilot) campaigns, one workspace has a connected mailbox, and the autopilot daily cap defaults to 25. So once re-crawls run, up to 30 of these become real cold emails sent without further human approval. That is the intended product behaviour and it is worth knowing before the re-crawls are triggered, which is why this PR does not trigger them.

Verification

  • bun run check — format, typecheck, 867 tests pass, 0 fail (up from 855)
  • 8 new tests in research-loop.test.ts: untitled signal, evidence content, relevance, team-page title promotion, inbox on a followed page, footer social kept, dead sub-link tolerated, supersession
  • 4 new tests in research-approve.test.ts: crawl queued with the right campaign, no-domain path, outbound queues nothing, card still approved
  • Migrations run in every seeded test, so 0014/0015 are proven to apply

🤖 Generated with Claude Code

The approvals queue held 73 cards, all `refresh_research`, none with a
signal. That looked like a broken composer and was really a closed
cycle, and the cause was one condition.

`storeSiteSignal` returned early when a crawled person had no job title.
The correlation in production was exact: of 208 people, all 131 with a
title had a signal and 76 of the 77 without had none — and every one of
the 73 pending cards was an untitled person. No signal means
`generateRecommendation` refuses an outbound action (outreach with no
reason is the thing the product exists to avoid) and falls back to
`refresh_research`, which nothing executed. The card that existed
*because* there was nothing to say could never produce anything to say.

The gate was never a policy decision. "Listed as Office Manager on the
company website" and "named on the company website" are equally
unrelated to buying intent; the title was load-bearing only because the
summary string needed it to be grammatical. An untitled listing is now
stored at relevance 0.35 against 0.5 for a stated role — the weight is
`decay x confidence x relevance` against a 0.15 floor, so it triggers
while fresh and stops by sixty days, which is the right shape for a
thinner pretext.

Four things follow from that, and each was its own dead end.

**A homepage is the wrong place to look for staff.** It describes the
company, links to `/team`, and names nobody. The crawl now follows up to
three same-origin links whose path or anchor text looks like a team,
about or contact page. Bounded on purpose: a fixed shortlist rather than
a spider, `robots.txt` fetched once and passed to every page, and the
model asked at most once for the whole site after the cheap
deterministic reads are merged — so four pages cost four fetches and the
expensive pass happens only if all four together named nobody. Merging
prefers the titled record, which is the entire point: `/` saying "Dana
Whitfield" and `/team` saying "Dana Whitfield, Clinic Director" are one
person, and the second is the one that produces a usable signal.

**Approving research now causes research.** `refresh_research` had no
executor anywhere — the runner knows four job kinds and this was not one
— so approving one wrote three rows and stopped. It now queues a
`crawl_site` for the company's own domain, which is what "research"
means for a prospect found by crawling. A person with no company on file
says so rather than queueing a job that cannot run.

**Recommendations are reconciled, not just inserted.** Re-reading a site
would otherwise stack a second pending card on the same person, leaving
the old research card beside the new email one. Only pending internal
actions are superseded; an outbound card may carry a drafted message and
a half-made human decision, and replacing that silently would discard
real work.

**Having something to say does not guarantee a way to say it.** A
prospect reachable only on `website` has no permitted engagement action,
and returning nothing there made them vanish — no card, no trace of a
lead already enriched, resolved and scored. The engine now falls back to
the passive set, so they stay visible as research, which is the honest
description of the state.

Migration 0015 backfills the signal the fixed code would have written,
for the 75 people already crawled without one. It writes no
recommendations, so nothing is sent by it: autopilot only ever picks up
pending `send_email` rows, and those are regenerated by a crawl. Dated
to the person's `created_at`, not now, so the decay curve treats the
evidence as the age it really is.

867 tests pass, 12 of them new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 7ba273a into main Aug 17, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant