Repository navigation
Break the research loop: an untitled name is still a fact worth citing - #40
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.storeSiteSignalreturned early when a crawled person had no job title. The correlation across all 208 people in production is exact:Every one of the 73 pending cards was an untitled person. No signal →
generateRecommendationrefuses outbound (outreach with no reason is what the product exists to avoid) → falls back torefresh_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.35against0.5for a stated role. Weight isdecay × confidence × relevanceagainst a0.15floor, so0.315fresh and0.205at 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.txtfetched 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_researchhad no executor anywhere; the runner knows four job kinds and this was not one. It now queues acrawl_sitefor 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
websitemeans 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_atrather 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 arerefresh_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)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, supersessionresearch-approve.test.ts: crawl queued with the right campaign, no-domain path, outbound queues nothing, card still approved0014/0015are proven to apply🤖 Generated with Claude Code