Skip to content

Commit 20b2f8f

Browse files
mpstatonclaude
andcommitted
feat(mega-gifts-ingest), issue(funder-strategy): strategy tags land on 69 funders; the edge question is logged, not built
Funders now answer "which strategies do I touch?" on their own cards: 140 Train-Case strategy tags (Ballmer ← Workforce-Development + Frontier-Job-Demand + Agent-Workflow-Maxxing + NCAD-Forge; the AI trio ← Agent-Workflow-Maxxing; rural funders ← Rural-Income-Boosts) backfilled from the mega-gifts CSV. Resolution derives from what the ingest actually wrote — an org is a row's funder iff the row's source_url sits on its corpus — so no name re-matching and no drift from the reviewed overrides. Idempotent; re-runs free; tags seeded tag_vocab and flow into the CRM export's tags column. The deeper question — a first-class funder→strategy edge carrying evidence counts, gift magnitudes, and recency instead of a bit — is deferred by operator ruling and logged as a context-v issue with the design sketch and the CSV named as the backfill source when it comes due. Files changed: - scripts/tag-funder-strategies-from-mega-gifts.mjs (new) - context-v/issues/Funder-Strategy-Connection-Tags-Now-Edges-Maybe.md (new) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent 99ffa96 commit 20b2f8f

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
title: "Funder↔strategy is tags for now — whether it deserves a real edge is deferred"
3+
lede: "Strategy tags landed on 69 funders from the mega-gifts data (Ballmer ← Workforce-Development, the AI trio ← Agent-Workflow-Maxxing) — but a tag can't carry evidence counts, gift sizes, or recency, and someday the question of a first-class funder→strategy edge comes due."
4+
date_created: 2026-07-28
5+
date_modified: 2026-07-28
6+
authors:
7+
- Michael Staton
8+
augmented_with:
9+
- Claude Code on Claude Fable 5
10+
semantic_version: 0.0.0.1
11+
tags:
12+
- Issue
13+
- Augment-It
14+
- Data-Modeling
15+
- Strategies
16+
- Funders
17+
status: Active
18+
---
19+
20+
# Funder↔strategy: tags now, edges maybe
21+
22+
## What shipped (2026-07-28)
23+
24+
`scripts/tag-funder-strategies-from-mega-gifts.mjs` — each funder org
25+
carries the Train-Case form of every strategy its mega-gift rows
26+
referenced, as ordinary `has_tag` observations (140 tags across 69
27+
funders on first run). Resolution derives from what the ingest actually
28+
wrote: an org is a row's funder iff the row's `source_url` sits on that
29+
org's corpus — no name re-matching. Tags show on the workbench card,
30+
filter in the CRM export's `tags` column, and seeded `tag_vocab` so the
31+
strategy names autocomplete everywhere tags are typed.
32+
33+
Also true and worth remembering: the connection exists a second way,
34+
**implicitly through shared content** — the same article registers on
35+
both the funder's `org_corpus` and the strategy's `source_usages`, so
36+
funder↔strategy is always derivable by URL join, with the article itself
37+
as the evidence.
38+
39+
## The deferred question
40+
41+
A tag is a bit. The real relationship has weight:
42+
43+
- **Evidence count** — Ballmer touches workforce-development via four
44+
gift rows; AT&T via one. The tag renders both identically.
45+
- **Magnitude** — a $250M pledge and a $50K grant tag the same.
46+
- **Recency/decay** — a 2019 gift and a 2026 gift tag the same; funder
47+
interest drifts.
48+
- **Provenance** — which articles/gifts justify the tag (today: answerable
49+
only by the URL join).
50+
51+
If reach-edu's core product question becomes "which funders are most
52+
aligned with strategy X, ranked" — the answer wants a first-class
53+
**funder→strategy edge** (an `org_domain_relations`-style RELATE, or the
54+
existing `affiliations` table with a third edge_type) carrying
55+
`evidence_count`, `total_amount`, `last_evidence_at`, and refs to the
56+
content_items that justify it — probably MAINTAINED by the ingest paths
57+
rather than authored by hand.
58+
59+
Not now, per operator ruling ("let's not worry about the deeper design
60+
question"). The observation-log design means tags today don't preclude
61+
edges later; the mega-gifts CSV (amounts included) remains the backfill
62+
source whenever this comes due.
63+
64+
## See also
65+
66+
- `scripts/tag-funder-strategies-from-mega-gifts.mjs` — the shipped pass.
67+
- `scripts/ingest-mega-gifts-by-topic.mjs` — the ingest whose writes the
68+
resolution derives from.
69+
- [[Relation-Kinds-Are-Inverse-Pairs]] — the sibling deferred design
70+
question in the same relations space.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env node
2+
// ============================================================================
3+
// tag-funder-strategies-from-mega-gifts.mjs
4+
//
5+
// Backfills strategy tags onto funder orgs from the mega-gifts CSV
6+
// (operator ask 2026-07-28): each funder gets the Train-Case form of every
7+
// strategy its gift rows referenced (ballmer-group ← Workforce-Development).
8+
//
9+
// Resolution is derived from what the ingest ACTUALLY wrote — an org is a
10+
// row's funder iff the row's source_url sits on that org's corpus — so no
11+
// name re-matching and no drift from the reviewed OVERRIDES table.
12+
// organization.tag.add is idempotent (created:false on repeats); re-runs
13+
// are free. Tags land in tag_vocab, the workbench card, and the CRM
14+
// export's tags column.
15+
//
16+
// Usage: set -a; source ./.env; set +a
17+
// node scripts/tag-funder-strategies-from-mega-gifts.mjs [--live]
18+
// ============================================================================
19+
20+
import { createRequire } from 'node:module';
21+
import { readFileSync } from 'node:fs';
22+
import { resolve } from 'node:path';
23+
24+
const requireScripts = createRequire(new URL('./package.json', import.meta.url));
25+
const requireServices = createRequire(new URL('../services/social-search/package.json', import.meta.url));
26+
const { Surreal } = requireScripts('surrealdb');
27+
const { connect } = requireServices('@nats-io/transport-node');
28+
29+
const args = { csv: 'clients/reach-edu/outputs/2026-07-28_mega-gifts-by-topic/mega-gifts-by-topic.csv', client: 'reach-edu', live: false };
30+
for (let i = 2; i < process.argv.length; i += 1) {
31+
if (process.argv[i] === '--live') args.live = true;
32+
else if (process.argv[i] === '--csv') args.csv = process.argv[++i];
33+
}
34+
35+
// slug → Train-Case tag (acronyms uppercase per the house tagging rule).
36+
const ACRONYMS = new Set(['ncad', 'ai']);
37+
const trainCase = (slug) =>
38+
slug.split('-').map((w) => (ACRONYMS.has(w) ? w.toUpperCase() : w[0].toUpperCase() + w.slice(1))).join('-');
39+
40+
function parseCsv(path) {
41+
const text = readFileSync(path, 'utf8');
42+
const rows = [];
43+
let row = [], cell = '', inQ = false;
44+
for (let i = 0; i < text.length; i += 1) {
45+
const c = text[i];
46+
if (inQ) {
47+
if (c === '"' && text[i + 1] === '"') { cell += '"'; i += 1; }
48+
else if (c === '"') inQ = false;
49+
else cell += c;
50+
} else if (c === '"') inQ = true;
51+
else if (c === ',') { row.push(cell); cell = ''; }
52+
else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; }
53+
else if (c !== '\r') cell += c;
54+
}
55+
if (row.length > 1) rows.push(row);
56+
const [h, ...data] = rows;
57+
return data.filter((r) => r.length > 1).map((r) => Object.fromEntries(h.map((c, i) => [c, (r[i] ?? '').trim()])));
58+
}
59+
const rows = parseCsv(resolve(args.csv));
60+
61+
// url → org slugs, from the live corpus (what the ingest wrote).
62+
const db = new Surreal();
63+
await db.connect(process.env.SURREAL_URL);
64+
await db.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS });
65+
await db.use({ namespace: process.env.SURREAL_NS, database: process.env.SURREAL_DB });
66+
const orgs = (await db.query(
67+
`SELECT slug, org_corpus FROM organizations WHERE client_access CONTAINS $client;`,
68+
{ client: args.client },
69+
))?.[0] ?? [];
70+
await db.close();
71+
const slugsByUrl = new Map();
72+
for (const o of orgs) {
73+
for (const e of o.org_corpus ?? []) {
74+
if (!e.url) continue;
75+
slugsByUrl.set(e.url, [...(slugsByUrl.get(e.url) ?? []), o.slug]);
76+
}
77+
}
78+
79+
// funder slug → set of strategy tags.
80+
const tagsBySlug = new Map();
81+
for (const r of rows) {
82+
const slugs = slugsByUrl.get(r.source_url) ?? [];
83+
const tags = r.strategy_slugs.split(/[|;,]/).map((x) => x.trim()).filter(Boolean).map(trainCase);
84+
for (const slug of slugs) {
85+
const set = tagsBySlug.get(slug) ?? new Set();
86+
for (const t of tags) set.add(t);
87+
tagsBySlug.set(slug, set);
88+
}
89+
}
90+
console.log(`funders to tag: ${tagsBySlug.size}`);
91+
for (const [slug, tags] of [...tagsBySlug.entries()].sort()) console.log(` ${slug}${[...tags].join(', ')}`);
92+
93+
if (!args.live) { console.log('\nDRY-RUN — nothing written. Re-run with --live.'); process.exit(0); }
94+
95+
const nc = await connect({ servers: process.env.NATS_URL ?? 'nats://localhost:4222' });
96+
const req = async (s, b) => JSON.parse(new TextDecoder().decode((await nc.request(s, JSON.stringify(b), { timeout: 20_000 })).data));
97+
let added = 0, existed = 0, failed = 0;
98+
for (const [slug, tags] of tagsBySlug) {
99+
for (const tag of tags) {
100+
const r = await req('organization.tag.add.requested', { org_slug: slug, tag, client: args.client });
101+
if (r.ok && r.created) added += 1;
102+
else if (r.ok) existed += 1;
103+
else { failed += 1; console.log(` ✗ ${slug}${tag}: ${r.error}`); }
104+
}
105+
}
106+
console.log(`\ntags added: ${added} · already present: ${existed} · failures: ${failed}`);
107+
await nc.drain();

0 commit comments

Comments
 (0)