@odla-ai/kg 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,10 +103,153 @@ site, blog, Substack, LinkedIn, X/Bluesky, GitHub, employer, and business contac
103
103
  points the person or their employer published. Never home addresses, personal
104
104
  phones, or personal email.
105
105
 
106
+ ## Grading, not self-reported confidence
107
+
108
+ `gradeSources()` derives how well an identity is established from the sources
109
+ that were actually checked, rather than from what a model said about its own
110
+ certainty. Measured, that number is not evidence: across real runs every lane
111
+ confirmed between one and three of four people who do not exist, confidently.
112
+
113
+ ```ts
114
+ const report = gradeSources([
115
+ { sourceUrl: "https://acme.example/team", sourceClass: "first-party",
116
+ facts: ["employer", "title", "location"], verifiedOn: "2026-08-03",
117
+ corroboration: "confirmed" },
118
+ { sourceUrl: "https://press.example/x", sourceClass: "third-party",
119
+ facts: ["employer"], verifiedOn: "2026-08-03", corroboration: "confirmed" },
120
+ ]);
121
+ // → { grade: "high", independentSources: 2, hasAnchor: true, reason: "…" }
122
+ ```
123
+
124
+ `high` needs independent sources — two pages of one site agree by construction —
125
+ with at least one **first-party or authoritative** anchor and enough agreeing
126
+ facts, and no source naming somebody else. `DEFAULT_STANDARD` is those
127
+ thresholds; pass your own `GradeStandard` to tighten them without touching code.
128
+ Worth knowing before you act on `medium`: two third-party sources agreeing about
129
+ a person who does not exist reached it in a real run, which is what
130
+ `anchorRequiredForMedium` exists to close.
131
+
132
+ `unverifiable` is deliberately distinct from `unresolved`. In one run 60 of 93
133
+ claimed URLs came back unreachable, almost all login walls. Calling those "not
134
+ established" blames the search for a wall it cannot walk through, and calling
135
+ them established would be a lie.
136
+
137
+ Sources are what you store; the grade is computed. That ordering is the point —
138
+ changing the standard re-scores stored evidence instead of re-running inference.
139
+
106
140
  Use `identityKey` (not `slugify`) to decide whether two records are the same
107
141
  entity: it folds away punctuation, accents, `&`, and trailing legal suffixes, so
108
142
  "Acme Corp.", "Acme, Inc." and "Acme & Co" agree.
109
143
 
144
+ ## Strategies: gather evidence several ways, grade it once
145
+
146
+ A single research call is the wrong unit. Measured over real runs, a grounded
147
+ research pass found 14 of 18 real people — and also "confirmed" one of four
148
+ people who do not exist. Walking the employer's own website found only 5 of 18,
149
+ and confirmed none of the four, because a company's site does not contain
150
+ someone who does not work there.
151
+
152
+ They fail in opposite directions, so `investigatePerson` runs several and lets
153
+ `gradeSources` settle it. Composed, the two above reached `high` on 16 of 18,
154
+ with none of the four fabricated people getting there — a bar neither clears
155
+ alone.
156
+
157
+ ```ts
158
+ import { investigatePerson, researchStrategy, orgSiteStrategy, targetedProfileStrategy } from "@odla-ai/kg";
159
+
160
+ const result = await investigatePerson(
161
+ {
162
+ person: { slug: "abbey-chrystal", name: "Abbey Chrystal",
163
+ companyName: "Rhizos", companyWebsite: "https://rhizos.example" },
164
+ retrievedAt: "2026-08-03",
165
+ ai, models: { search: "…", extract: "…" },
166
+ readPage, // see below
167
+ },
168
+ [researchStrategy, orgSiteStrategy, targetedProfileStrategy],
169
+ );
170
+ // → { grade: "high", sources: [...], links: [...], findings: [...], skipped: [] }
171
+ ```
172
+
173
+ Strategies run concurrently and are isolated: one that throws becomes a
174
+ `Finding` carrying its error, so a search provider being down costs you that
175
+ strategy's evidence rather than the first-party page another already read. One
176
+ that cannot run — `orgSiteStrategy` with no `companyWebsite` — reports itself
177
+ inapplicable instead of pretending to have checked, and is named in `skipped`.
178
+
179
+ A strategy never decides whether the person was found; it reports what it saw
180
+ and how strong the source was. That split is why adding a strategy cannot
181
+ inflate a verdict, and why `researchStrategy` enters its sources at `name-only`
182
+ at best however confident the model sounded.
183
+
184
+ `readPage` is yours to supply, deliberately. Fetched pages are attacker-
185
+ influenced, so kg never reaches for the network itself and cannot quietly bypass
186
+ whatever boundary you chose. In odla that is `@odla-ai/camel`'s artifact
187
+ ingress, which caps bytes, screens redirects for SSRF, and labels the body
188
+ `Unsafe`:
189
+
190
+ ```ts
191
+ const web = createWebArtifactIngress({ ingress, readers, limits: { maximumBytes: 512 * 1024 } });
192
+ const readPage = async (url: string) => {
193
+ try { return (await web.fetchArtifact(url)).body.value; } catch { return null; }
194
+ };
195
+ ```
196
+
197
+ ## A person's key should not be their name
198
+
199
+ kg has been writing person nodes keyed on `slugify(name)`, and that fails in
200
+ both directions. Two people called John Smith at two companies collapse into one
201
+ node, silently merging their employers, links, and evidence. And a person whose
202
+ name is written differently by the next source — a married name, an accent
203
+ restored, "Bob" where the last pass saw "Robert" — mints a second node that
204
+ nothing will reconcile.
205
+
206
+ `identifyPerson` mints an opaque key once and indexes the ways that person is
207
+ recognized alongside it:
208
+
209
+ ```ts
210
+ const { personKey, minted, fragment } = await identifyPerson(
211
+ db,
212
+ { aliasType: "person_alias" },
213
+ person,
214
+ result.links,
215
+ result.fragment, // keyed on person.slug, as personToFragment produces
216
+ );
217
+ await writeFragment(db, fragment, ontology, mutationId);
218
+ ```
219
+
220
+ The fragment comes back rekeyed onto `personKey`, with every edge that touched
221
+ the old key moved with it, plus one alias row per alias. Those rows are what
222
+ make the next pass idempotent — without them a minted key would be re-minted
223
+ every run, which is strictly worse than the name key it replaces.
224
+
225
+ Alias order is the contract. A public profile belongs to the person and survives
226
+ them changing jobs, so `linkedin:/in/abbeychrystal` is tried before
227
+ `name:abbeychrystal~rhizosviticulture`. The name alias includes the employer
228
+ because that is what tells namesakes apart; it is the bootstrap for someone with
229
+ no profile found yet, and they graduate off it as soon as any pass finds one —
230
+ the new profile alias attaches to the key they already have.
231
+
232
+ A person with no employer *and* no profile gets no alias at all, so every pass
233
+ mints them a fresh key. That is deliberate: duplicates are recoverable, a merged
234
+ pair of strangers is not.
235
+
236
+ **Adopting it.** The alias entity is yours to declare — kg reads and writes it
237
+ through the names in `PersonIdentityConfig` — so add one to your ontology with a
238
+ unique natural key and an attribute holding the person key:
239
+
240
+ ```ts
241
+ person_alias: {
242
+ naturalKey: "alias",
243
+ attrs: { alias: { type: "string", unique: true }, personKey: { type: "string", indexed: true } },
244
+ }
245
+ ```
246
+
247
+ Existing graphs keep working: `personToFragment` still keys on `person.slug`,
248
+ and nothing changes until you call `identifyPerson`. To migrate, backfill one
249
+ `name:` alias row per existing person pointing at their current key, then start
250
+ identifying — already-keyed people resolve to themselves and pick up profile
251
+ aliases as passes find them.
252
+
110
253
  ## Provenance & merging
111
254
 
112
255
  `writeFragment` reads existing nodes by natural key (one batched query), merges