@polycode-projects/the-mechanical-code-talker 6.0.13 → 6.0.15
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/package.json +1 -1
- package/src/adapters/corpus/news-sources.mjs +59 -4
- package/src/adapters/memory/core.mjs +192 -39
- package/src/adapters/memory/shacl.mjs +2 -2
- package/src/domain/memory/trust.mjs +78 -17
- package/src/domain/news-feed.mjs +74 -10
- package/src/domain/term-ledger.mjs +29 -1
- package/src/services/chat.mjs +18 -10
- package/src/services/extract-facts.mjs +53 -7
- package/src/services/news.mjs +2 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +122 -122
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.15",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
|
|
@@ -298,6 +298,30 @@ async function fetchWikimediaFeed(record, gate, { now }) {
|
|
|
298
298
|
return { items, bytes: jsonByteLength(body) };
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
// A Hacker News item is a headline and nothing else — the API carries no
|
|
302
|
+
// summary field to fetch — and a headline is rarely a sentence, so nothing in
|
|
303
|
+
// the item grounds on its own. The fixed sentence below states what the site
|
|
304
|
+
// itself did with the story, and it names the headline IN QUOTES: quoted, the
|
|
305
|
+
// headline's own words can never be re-read as a claim of their own, so "Let's
|
|
306
|
+
// take apart your phone" stays a story Hacker News discusses instead of
|
|
307
|
+
// becoming a fact about Hacker News taking a phone apart.
|
|
308
|
+
//
|
|
309
|
+
// "Hackernews" is one word here on purpose. A two-word proper name in subject
|
|
310
|
+
// position reads as noun + verb ("Hacker" doing "News"), and the fact that
|
|
311
|
+
// falls out of that is nonsense.
|
|
312
|
+
const HACKER_NEWS_TITLE_PREFIX_RE = /^(?:show|ask|tell)\s+hn:\s*/i;
|
|
313
|
+
// Past this many words the stored term is a clause, not a name, and the
|
|
314
|
+
// recognizer turns it down — so the sentence is not built at all rather than
|
|
315
|
+
// stored half-read.
|
|
316
|
+
const HACKER_NEWS_HEADLINE_MAX_WORDS = 6;
|
|
317
|
+
|
|
318
|
+
function hackerNewsSummary(title) {
|
|
319
|
+
const headline = String(title || "").replace(HACKER_NEWS_TITLE_PREFIX_RE, "").trim();
|
|
320
|
+
if (!headline || headline.includes(":")) return "";
|
|
321
|
+
if (headline.split(/\s+/).length > HACKER_NEWS_HEADLINE_MAX_WORDS) return "";
|
|
322
|
+
return `Hackernews discusses "${headline}".`;
|
|
323
|
+
}
|
|
324
|
+
|
|
301
325
|
/** topstories.json, then item/<id>.json for the first ten ids, through the
|
|
302
326
|
* same gate — each item fetch takes its own slot, so the ten round trips
|
|
303
327
|
* are genuinely paced by the gate's minIntervalMs rather than firing back
|
|
@@ -311,11 +335,12 @@ async function fetchHackerNews(record, gate, { now }) {
|
|
|
311
335
|
const item = await pacedFetchJson(gate, `${record.url}/item/${id}.json`);
|
|
312
336
|
if (!item) continue;
|
|
313
337
|
bytes += jsonByteLength(item);
|
|
338
|
+
const title = stripMarkup(item.title || "");
|
|
314
339
|
raw.push({
|
|
315
340
|
guid: String(item.id ?? id),
|
|
316
|
-
title
|
|
341
|
+
title,
|
|
317
342
|
url: item.url || `https://news.ycombinator.com/item?id=${item.id ?? id}`,
|
|
318
|
-
summary:
|
|
343
|
+
summary: hackerNewsSummary(title),
|
|
319
344
|
publishedAt: Number.isFinite(item.time) ? new Date(item.time * 1000).toISOString() : "",
|
|
320
345
|
});
|
|
321
346
|
}
|
|
@@ -323,6 +348,32 @@ async function fetchHackerNews(record, gate, { now }) {
|
|
|
323
348
|
return { items, bytes };
|
|
324
349
|
}
|
|
325
350
|
|
|
351
|
+
// A USGS place names a point by its distance and bearing from a settlement
|
|
352
|
+
// ("25 km ENE of Wana, Pakistan"), or by an offshore bearing ("off the west
|
|
353
|
+
// coast of Vancouver Island, Canada"), or outright ("Western Australia"). The
|
|
354
|
+
// distance and the bearing are a measurement, not a name, so they come off
|
|
355
|
+
// before the place reaches the sentence below: a number-led term can never
|
|
356
|
+
// head a feed card, and "near" already covers the few kilometres dropped.
|
|
357
|
+
const USGS_DISTANCE_PREFIX_RE = /^\s*\d+(?:\.\d+)?\s*km\s+[NSEW]{1,3}\s+of\s+/i;
|
|
358
|
+
const USGS_OFFSHORE_PREFIX_RE = /^\s*off\s+(?:the\s+)?[a-z\s]*?coast\s+of\s+/i;
|
|
359
|
+
|
|
360
|
+
function usgsPlaceName(place) {
|
|
361
|
+
return String(place || "")
|
|
362
|
+
.replace(USGS_DISTANCE_PREFIX_RE, "")
|
|
363
|
+
.replace(USGS_OFFSHORE_PREFIX_RE, "")
|
|
364
|
+
.trim();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// One fixed sentence per quake: a bare-noun subject, the verb the event did,
|
|
368
|
+
// and the named place. The magnitude stays where USGS itself put it, in the
|
|
369
|
+
// item's title — a stored fact's subject here is the CLASS "earthquake", so
|
|
370
|
+
// writing the number into this sentence would say something about earthquakes
|
|
371
|
+
// in general rather than about this one.
|
|
372
|
+
function usgsSummary(place) {
|
|
373
|
+
const name = usgsPlaceName(place);
|
|
374
|
+
return name ? `An earthquake struck near ${name}.` : "";
|
|
375
|
+
}
|
|
376
|
+
|
|
326
377
|
async function fetchUsgs(record, gate, { now }) {
|
|
327
378
|
const body = await pacedFetchJson(gate, record.url);
|
|
328
379
|
if (body === null) return null;
|
|
@@ -332,7 +383,7 @@ async function fetchUsgs(record, gate, { now }) {
|
|
|
332
383
|
guid: String(f?.id ?? f?.properties?.detail ?? f?.properties?.url ?? ""),
|
|
333
384
|
title: stripMarkup(f?.properties?.title || ""),
|
|
334
385
|
url: f?.properties?.url || "",
|
|
335
|
-
summary: stripMarkup(
|
|
386
|
+
summary: stripMarkup(usgsSummary(f?.properties?.place)),
|
|
336
387
|
publishedAt: Number.isFinite(f?.properties?.time) ? new Date(f.properties.time).toISOString() : "",
|
|
337
388
|
}));
|
|
338
389
|
const items = normalizeFeedItems(record.id, raw, { now });
|
|
@@ -347,7 +398,11 @@ function wikinewsArticleOrigin(apiUrl) {
|
|
|
347
398
|
* recentchanges list — the smallest query that names what is new without a
|
|
348
399
|
* category to maintain by hand. */
|
|
349
400
|
async function fetchWikinews(record, gate, { now }) {
|
|
350
|
-
|
|
401
|
+
// `rctype=new` is the action API's own name for "page creations only". An
|
|
402
|
+
// earlier `rcnewonly` is not a parameter the API has: it answers with an
|
|
403
|
+
// "Unrecognized parameter" warning and lists every edit, so a re-edited old
|
|
404
|
+
// article read as a new story.
|
|
405
|
+
const url = `${record.url}?action=query&list=recentchanges&rcnamespace=0&rctype=new&rclimit=20&format=json&formatversion=2&origin=*`;
|
|
351
406
|
const body = await pacedFetchJson(gate, url);
|
|
352
407
|
if (body === null) return null;
|
|
353
408
|
if (isNotModified(body)) return { items: [], bytes: 0, notModified: true };
|
|
@@ -2421,12 +2421,14 @@ function sourceIdFor(desc) {
|
|
|
2421
2421
|
case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
|
|
2422
2422
|
case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
|
|
2423
2423
|
case "corpusWeak": return { id: `src:corpus-weak:${desc.name}`, type: "corpusWeak" };
|
|
2424
|
-
// One Source per
|
|
2425
|
-
//
|
|
2426
|
-
|
|
2427
|
-
//
|
|
2424
|
+
// One Source per reference WORK, not per article: Simple English Wikipedia
|
|
2425
|
+
// is one party however many of its pages get read, so two of its articles
|
|
2426
|
+
// stating the same triple corroborate nothing. Which article said it stays
|
|
2427
|
+
// on the fact's own provenance tag, where the audit trail belongs.
|
|
2428
|
+
case "reference": return { id: `src:reference:${desc.pack}`, type: "reference" };
|
|
2429
|
+
// The live-Wikipedia pack: same per-work Source id, but a lower trust
|
|
2428
2430
|
// type so a live lookup ranks below the curated revision-pinned pack.
|
|
2429
|
-
case "referenceLive": return { id: `src:reference:${desc.pack}
|
|
2431
|
+
case "referenceLive": return { id: `src:reference:${desc.pack}`, type: "referenceLive" };
|
|
2430
2432
|
// One Source per source-file basename, not per extraction run.
|
|
2431
2433
|
case "extracted": return { id: `src:extracted:${desc.name}`, type: "extracted" };
|
|
2432
2434
|
// The fuzzy tier's candidates: one low-trust Source per source label.
|
|
@@ -2600,6 +2602,49 @@ const isSessionScopedSourceId = (id) =>
|
|
|
2600
2602
|
|| id.startsWith(`${TEACH_SOURCE_ID}:`)
|
|
2601
2603
|
|| id.startsWith(`${TEACH_NODE_SOURCE_ID}:`));
|
|
2602
2604
|
|
|
2605
|
+
/**
|
|
2606
|
+
* The slice of the graph a reliability pass can possibly read: the triples a
|
|
2607
|
+
* session-scoped Source stated, and every (subject, predicate) those triples
|
|
2608
|
+
* sit under so each one's disagreeing siblings come along. Null when nothing
|
|
2609
|
+
* in the store has a track record to keep, which is the whole of what a
|
|
2610
|
+
* seed-only graph or a store whose writers are all documents ever needs.
|
|
2611
|
+
*
|
|
2612
|
+
* A pure narrowing, not a cache: the answer is still folded from the fact set
|
|
2613
|
+
* on every call, and every group left out is one whose row could not have
|
|
2614
|
+
* changed a number in the tally. A Source nobody stated anything for keeps no
|
|
2615
|
+
* reliability attribute either way, so leaving it out is what the whole-graph
|
|
2616
|
+
* pass does too.
|
|
2617
|
+
*/
|
|
2618
|
+
function sessionScopedFoldScope(payload) {
|
|
2619
|
+
// The Source list is a handful of individuals where the edge list is one per
|
|
2620
|
+
// fact record, so "is there an actor here at all" is asked of the Sources.
|
|
2621
|
+
const idx = memoryIndexOf(payload);
|
|
2622
|
+
if (idx) {
|
|
2623
|
+
let anyActor = false;
|
|
2624
|
+
for (const id of idx.sourcesById.keys()) if (isSessionScopedSourceId(id)) { anyActor = true; break; }
|
|
2625
|
+
if (!anyActor) return null;
|
|
2626
|
+
}
|
|
2627
|
+
const statedGroup = payload.objectProperties.find((g) => g?.prop === STATED_BY_PROP);
|
|
2628
|
+
const statedRecordIds = new Set();
|
|
2629
|
+
for (const e of statedGroup?.examples || []) {
|
|
2630
|
+
if (isSessionScopedSourceId(e?.object)) statedRecordIds.add(e.subject);
|
|
2631
|
+
}
|
|
2632
|
+
const scopedGroups = new Set();
|
|
2633
|
+
const pairs = new Set();
|
|
2634
|
+
for (const ind of payload.individuals) {
|
|
2635
|
+
if (ind?.class !== FACT_CLASS) continue;
|
|
2636
|
+
// A summary that absorbed an actor's record still votes for it, so the
|
|
2637
|
+
// group it stands in is in scope exactly as the record itself would be.
|
|
2638
|
+
const stated = statedRecordIds.has(ind.id)
|
|
2639
|
+
|| (isHeadRollupId(ind.id) && absorbedSourceIds(ind).some(isSessionScopedSourceId));
|
|
2640
|
+
if (!stated) continue;
|
|
2641
|
+
scopedGroups.add(factGroupId(ind.id));
|
|
2642
|
+
const subject = individualKey(ind, "subject");
|
|
2643
|
+
if (subject) pairs.add(subjectPredicateKey(subject, individualKey(ind, "predicate")));
|
|
2644
|
+
}
|
|
2645
|
+
return scopedGroups.size ? { pairs, scopedGroups } : null;
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2603
2648
|
/**
|
|
2604
2649
|
* Recompute + materialise mgx:sourceReliability on every session-scoped
|
|
2605
2650
|
* operator/teach Source: count facts stated vs. contradicted
|
|
@@ -2610,11 +2655,17 @@ const isSessionScopedSourceId = (id) =>
|
|
|
2610
2655
|
*/
|
|
2611
2656
|
function recomputeSourceReliability(payload) {
|
|
2612
2657
|
if (!Array.isArray(payload?.individuals) || !Array.isArray(payload?.objectProperties)) return;
|
|
2613
|
-
const
|
|
2658
|
+
const scope = sessionScopedFoldScope(payload);
|
|
2659
|
+
if (!scope) return;
|
|
2660
|
+
|
|
2661
|
+
// Fact-only — contradiction accounting is inherently Fact-shaped. Scoped to
|
|
2662
|
+
// the triples a scorable actor actually stated, plus every sibling object
|
|
2663
|
+
// those triples could be contradicted by: no other group can put a number in
|
|
2664
|
+
// the tally below, so folding the rest of the graph only costs time.
|
|
2665
|
+
const rows = foldFactRows(payload, factFoldContext(payload, scope));
|
|
2614
2666
|
const contradictedFactIds = new Set();
|
|
2615
2667
|
// The fold above is the same one findContradictions would take for itself,
|
|
2616
|
-
// and
|
|
2617
|
-
// payload between the two, so it goes across.
|
|
2668
|
+
// and nothing changes the payload between the two, so it goes across.
|
|
2618
2669
|
for (const group of findContradictions(payload, { factRows: rows })) for (const r of group) contradictedFactIds.add(r.id);
|
|
2619
2670
|
|
|
2620
2671
|
const bySource = new Map(); // sessionSourceId -> { factsAsserted, factsContradicted }
|
|
@@ -3813,7 +3864,14 @@ export async function resolveRelationChaseReverse(memory, name, objectTerm, help
|
|
|
3813
3864
|
* answers "what did this source used to say", never "what do I trust now".
|
|
3814
3865
|
*/
|
|
3815
3866
|
export function readFactRows(memory, opts = {}) {
|
|
3816
|
-
|
|
3867
|
+
return foldFactRows(memory, factFoldContext(memory), opts);
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
/** The fold itself, over whatever slice of the graph a context was built for.
|
|
3871
|
+
* `readFactRows` hands it the whole graph; a caller that only needs certain
|
|
3872
|
+
* (subject, predicate) pairs hands it a scoped context and gets exactly the
|
|
3873
|
+
* rows a whole-graph fold would have produced for those pairs. */
|
|
3874
|
+
function foldFactRows(memory, ctx, opts = {}) {
|
|
3817
3875
|
// A materialised head, when the backend keeps one, replaces the group's own
|
|
3818
3876
|
// fold with the audit trail that fold was last built from — the same records,
|
|
3819
3877
|
// read back instead of re-derived. It carries no recency by construction, so
|
|
@@ -3840,17 +3898,47 @@ export function readFactRows(memory, opts = {}) {
|
|
|
3840
3898
|
* Shared by the read fold and by the head materialisation below, deliberately:
|
|
3841
3899
|
* a stored aggregate and a computed one folded from different inputs is the
|
|
3842
3900
|
* failure a materialised table invites, and one shared builder is what keeps
|
|
3843
|
-
* the two from ever drifting apart.
|
|
3844
|
-
|
|
3901
|
+
* the two from ever drifting apart.
|
|
3902
|
+
*
|
|
3903
|
+
* `pairs` narrows the context to the groups a given set of (subject,
|
|
3904
|
+
* predicate) keys carries, with `scopedGroups` naming groups to keep outright
|
|
3905
|
+
* whatever their records read as. Every group of a kept pair is kept, whoever
|
|
3906
|
+
* stated it, so a scoped fold reads each of those triples exactly as a
|
|
3907
|
+
* whole-graph fold does — and a caller asking about one party's facts still
|
|
3908
|
+
* sees every sibling object that party's claim could be contradicted by. */
|
|
3909
|
+
function factFoldContext(memory, { pairs = null, scopedGroups = new Set() } = {}) {
|
|
3845
3910
|
const individuals = memory?.individuals || [];
|
|
3846
|
-
const
|
|
3847
|
-
const
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3911
|
+
const idx = memoryIndexOf(memory);
|
|
3912
|
+
const sourcesById = idx
|
|
3913
|
+
? idx.sourcesById
|
|
3914
|
+
: new Map(individuals.filter((i) => i?.class === SOURCE_CLASS).map((i) => [i.id, i]));
|
|
3915
|
+
// The live index already keeps this map, edge for edge, and rebuilding it
|
|
3916
|
+
// allocates one array per fact record over the whole graph.
|
|
3917
|
+
let statedByRecord = idx?.statedByBySubject;
|
|
3918
|
+
if (!statedByRecord) {
|
|
3919
|
+
const statedGroup = (memory?.objectProperties || []).find((g) => g?.prop === STATED_BY_PROP);
|
|
3920
|
+
statedByRecord = new Map();
|
|
3921
|
+
for (const e of statedGroup?.examples || []) {
|
|
3922
|
+
if (!statedByRecord.has(e.subject)) statedByRecord.set(e.subject, []);
|
|
3923
|
+
statedByRecord.get(e.subject).push(e.object);
|
|
3924
|
+
}
|
|
3852
3925
|
}
|
|
3853
3926
|
|
|
3927
|
+
// Subject first, predicate only on a subject hit: reading both attributes off
|
|
3928
|
+
// every fact record in the graph is the whole cost of a scoped pass, and the
|
|
3929
|
+
// subject rules out nearly all of them on one lookup.
|
|
3930
|
+
const wantedSubjects = pairs ? new Set([...pairs].map((key) => key.slice(0, key.indexOf("")))) : null;
|
|
3931
|
+
const outsideScope = (ind) => {
|
|
3932
|
+
if (!pairs) return false;
|
|
3933
|
+
// A group the caller named outright is in whatever its records read as —
|
|
3934
|
+
// a summary standing for absorbed records carries only a copied template,
|
|
3935
|
+
// so its own attributes are not what places it.
|
|
3936
|
+
if (scopedGroups.has(factGroupId(ind.id))) return false;
|
|
3937
|
+
const subject = individualKey(ind, "subject");
|
|
3938
|
+
if (!wantedSubjects.has(subject)) return true;
|
|
3939
|
+
return !pairs.has(subjectPredicateKey(subject, individualKey(ind, "predicate")));
|
|
3940
|
+
};
|
|
3941
|
+
|
|
3854
3942
|
const groups = new Map();
|
|
3855
3943
|
const retractionsByGroup = new Map();
|
|
3856
3944
|
for (const ind of individuals) {
|
|
@@ -3864,6 +3952,7 @@ function factFoldContext(memory) {
|
|
|
3864
3952
|
if (ind?.class !== FACT_CLASS) continue;
|
|
3865
3953
|
if ((ind.attributes || []).some((a) => a?.prop === SUPERSEDED_BY_PROP)) continue; // a demoted leaf, not a head
|
|
3866
3954
|
if (isChainRollupId(ind.id)) continue; // a summary of one source's demoted history, which was never a vote
|
|
3955
|
+
if (outsideScope(ind)) continue;
|
|
3867
3956
|
const groupId = factGroupId(ind.id);
|
|
3868
3957
|
const group = groups.get(groupId);
|
|
3869
3958
|
if (group) group.push(ind);
|
|
@@ -3888,26 +3977,89 @@ function factFoldContext(memory) {
|
|
|
3888
3977
|
else groups.delete(groupId);
|
|
3889
3978
|
}
|
|
3890
3979
|
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3980
|
+
// Codepoint order on the record id, which sorts by source key — the same
|
|
3981
|
+
// locale-free determinism the P2P layer's own sort insists on, so two peers
|
|
3982
|
+
// holding the same records read the same row.
|
|
3983
|
+
for (const members of groups.values()) members.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
3984
|
+
|
|
3985
|
+
// One entry per distinct Source, not one attribute scan per record: a seed's
|
|
3986
|
+
// 60,000 facts name a handful of Sources between them.
|
|
3987
|
+
const typeBySource = new Map();
|
|
3988
|
+
const sourceTypeOf = (id) => {
|
|
3989
|
+
let type = typeBySource.get(id);
|
|
3990
|
+
if (type === undefined) {
|
|
3991
|
+
type = (sourcesById.get(id)?.attributes || []).find((a) => a?.prop === "mgx:sourceType")?.value || "";
|
|
3992
|
+
typeBySource.set(id, type);
|
|
3993
|
+
}
|
|
3994
|
+
return type;
|
|
3995
|
+
};
|
|
3996
|
+
|
|
3997
|
+
// Likewise for the timestamp a provenance string embeds: a corpus band writes
|
|
3998
|
+
// one tag over tens of thousands of records, and parsing it is the same
|
|
3999
|
+
// answer every time.
|
|
4000
|
+
const embeddedBySource = new Map();
|
|
4001
|
+
const embeddedTimestampOf = (provenance) => {
|
|
4002
|
+
let ts = embeddedBySource.get(provenance);
|
|
4003
|
+
if (ts === undefined) {
|
|
4004
|
+
ts = embeddedTagTimestamp(provenance.split(" | ").filter(Boolean));
|
|
4005
|
+
embeddedBySource.set(provenance, ts);
|
|
4006
|
+
}
|
|
4007
|
+
return ts;
|
|
4008
|
+
};
|
|
4009
|
+
|
|
4010
|
+
// Only the sqlite head materialisation walks siblings by pair, and building
|
|
4011
|
+
// the index costs two attribute reads per group — so it is built when asked
|
|
4012
|
+
// for and not before.
|
|
4013
|
+
let groupsByPair = null;
|
|
3902
4014
|
|
|
3903
4015
|
return {
|
|
3904
4016
|
groups,
|
|
3905
|
-
groupsByPair
|
|
4017
|
+
get groupsByPair() {
|
|
4018
|
+
if (groupsByPair) return groupsByPair;
|
|
4019
|
+
groupsByPair = new Map();
|
|
4020
|
+
for (const [groupId, members] of groups) {
|
|
4021
|
+
const key = subjectPredicateKey(individualKey(members[0], "subject"), individualKey(members[0], "predicate"));
|
|
4022
|
+
const held = groupsByPair.get(key);
|
|
4023
|
+
if (held) held.push(groupId);
|
|
4024
|
+
else groupsByPair.set(key, [groupId]);
|
|
4025
|
+
}
|
|
4026
|
+
return groupsByPair;
|
|
4027
|
+
},
|
|
3906
4028
|
statedByRecord,
|
|
3907
|
-
sourceTypeOf
|
|
4029
|
+
sourceTypeOf,
|
|
4030
|
+
embeddedTimestampOf,
|
|
3908
4031
|
};
|
|
3909
4032
|
}
|
|
3910
4033
|
|
|
4034
|
+
/** Everything the fold reads off one live head, gathered in ONE walk of its
|
|
4035
|
+
* attributes. Ten `.find()` scans of the same short array, once per record, is
|
|
4036
|
+
* the single most expensive thing a whole-graph fold does — the work is all in
|
|
4037
|
+
* the scanning, not in the reading. */
|
|
4038
|
+
function foldHeadFields(head) {
|
|
4039
|
+
let provenance = "";
|
|
4040
|
+
let createdAt = "";
|
|
4041
|
+
let observedAt = "";
|
|
4042
|
+
let extraction = "";
|
|
4043
|
+
let trustScore = "";
|
|
4044
|
+
let sourceId = "";
|
|
4045
|
+
let quantifier = "";
|
|
4046
|
+
let justification = "";
|
|
4047
|
+
for (const a of head?.attributes || []) {
|
|
4048
|
+
switch (a?.prop) {
|
|
4049
|
+
case "mgx:factProvenance": provenance = a.value || ""; continue;
|
|
4050
|
+
case CREATED_AT_PROP: createdAt = a.value || ""; continue;
|
|
4051
|
+
case OBSERVED_AT_PROP: observedAt = a.value || ""; continue;
|
|
4052
|
+
case EXTRACTION_FINDING_PROP: extraction = a.value || ""; continue;
|
|
4053
|
+
case TRUST_SCORE_PROP: trustScore = a.value || ""; continue;
|
|
4054
|
+
case SOURCE_ID_PROP: sourceId = a.value || ""; continue;
|
|
4055
|
+
default: break;
|
|
4056
|
+
}
|
|
4057
|
+
if (a?.key === "quantifier") quantifier = a.value || "";
|
|
4058
|
+
else if (a?.key === "justification") justification = a.value || "";
|
|
4059
|
+
}
|
|
4060
|
+
return { provenance, createdAt, observedAt, extraction, trustScore, sourceId, quantifier, justification };
|
|
4061
|
+
}
|
|
4062
|
+
|
|
3911
4063
|
/** One triple group folded into its row, minus the aggregate trust — that is
|
|
3912
4064
|
* the caller's, because it is the only part that depends on when you ask. */
|
|
3913
4065
|
function foldFactGroup(id, heads, ctx) {
|
|
@@ -3953,10 +4105,11 @@ function foldFactGroup(id, heads, ctx) {
|
|
|
3953
4105
|
});
|
|
3954
4106
|
continue;
|
|
3955
4107
|
}
|
|
3956
|
-
const
|
|
4108
|
+
const field = foldHeadFields(head);
|
|
4109
|
+
const headTags = field.provenance.split(" | ").filter(Boolean);
|
|
3957
4110
|
for (const tag of headTags) tags.add(tag);
|
|
3958
4111
|
const [statedBy] = statedByRecord.get(head.id) || [];
|
|
3959
|
-
const sourceId = statedBy ||
|
|
4112
|
+
const sourceId = statedBy || field.sourceId;
|
|
3960
4113
|
const sourceType = sourceTypeOf(sourceId);
|
|
3961
4114
|
// src:none stands for "no Source at all", so it stays out of the union a
|
|
3962
4115
|
// reader renders and out of the corroboration count, exactly as an
|
|
@@ -3965,9 +4118,9 @@ function foldFactGroup(id, heads, ctx) {
|
|
|
3965
4118
|
sourceIds.push(statedBy);
|
|
3966
4119
|
if (sourceType) sourceTypes.push(sourceType);
|
|
3967
4120
|
}
|
|
3968
|
-
const createdAt =
|
|
3969
|
-
const observedAt =
|
|
3970
|
-
const extraction =
|
|
4121
|
+
const createdAt = field.createdAt;
|
|
4122
|
+
const observedAt = field.observedAt;
|
|
4123
|
+
const extraction = field.extraction ? field.extraction.split(" ").filter(Boolean) : [];
|
|
3971
4124
|
for (const finding of extraction) findings.add(finding);
|
|
3972
4125
|
assertions.push({
|
|
3973
4126
|
id: head.id, sourceId, sourceType,
|
|
@@ -3975,13 +4128,13 @@ function foldFactGroup(id, heads, ctx) {
|
|
|
3975
4128
|
createdAt,
|
|
3976
4129
|
...(observedAt ? { observedAt } : {}),
|
|
3977
4130
|
...(extraction.length ? { extraction } : {}),
|
|
3978
|
-
ownTrust: Number(
|
|
3979
|
-
assertedAt:
|
|
4131
|
+
ownTrust: Number(field.trustScore) || 0,
|
|
4132
|
+
assertedAt: ctx.embeddedTimestampOf(field.provenance) || (Number.isFinite(Date.parse(createdAt)) ? createdAt : ""),
|
|
3980
4133
|
});
|
|
3981
|
-
quantifier = quantifier ||
|
|
4134
|
+
quantifier = quantifier || field.quantifier;
|
|
3982
4135
|
// ' | '-separated environments, one premise-id list per independent
|
|
3983
4136
|
// derivation; a legacy value with no ' | ' parses as one environment.
|
|
3984
|
-
for (const chunk of
|
|
4137
|
+
for (const chunk of field.justification.split(" | ")) {
|
|
3985
4138
|
const env = chunk.split(" ").filter(Boolean);
|
|
3986
4139
|
if (!env.length) continue;
|
|
3987
4140
|
const key = env.join(" ");
|
|
@@ -59,8 +59,8 @@ const SPANS_A_SENTENCE_BOUNDARY_RE = /[.!?]\s+\w/;
|
|
|
59
59
|
// never for the group part: a hand-built individual with a short opaque id is a
|
|
60
60
|
// legitimate sparse write, and rejecting it is exactly the false positive this
|
|
61
61
|
// gate must never produce. The source suffix is matched loosely on purpose — a
|
|
62
|
-
// Source id legitimately carries colons
|
|
63
|
-
// ("src:
|
|
62
|
+
// Source id legitimately carries colons and spaces of its own
|
|
63
|
+
// ("src:corpus:mud:amber fox").
|
|
64
64
|
const FACT_RECORD_ID_RE = /^[^@]+@(.+?)(#v[1-9][0-9]*)?$/;
|
|
65
65
|
const looksLikeFactRecordId = (id) => id.includes("@") || /#v\d/.test(id);
|
|
66
66
|
|
|
@@ -107,6 +107,60 @@ function parsePeerNodeTagRest(rest) {
|
|
|
107
107
|
const LIVE_REFERENCE_PACK = "wikipedia-live";
|
|
108
108
|
const referenceKindFor = (pack) => (pack === LIVE_REFERENCE_PACK ? "referenceLive" : "reference");
|
|
109
109
|
|
|
110
|
+
/** The part of a tag that names WHICH publication it came from, with the
|
|
111
|
+
* per-item tail cut off: `news:nyt-world@item-91` names the NYT world feed,
|
|
112
|
+
* and the item id beside it says which article, not which publisher. One feed
|
|
113
|
+
* is one asserting party however many articles it runs, so the tail stays on
|
|
114
|
+
* the tag for audit and out of the Source identity — the same split
|
|
115
|
+
* `world:<name>:turnN` and `mud:<character>:turnN` already make. */
|
|
116
|
+
const publicationKeyOf = (rest) => String(rest || "").split("@")[0];
|
|
117
|
+
|
|
118
|
+
/** The same cut for a tag the fuzzy tier labels its own Source with: the feed
|
|
119
|
+
* or the reference work, never the article. `news:nyt-world@item-91` folds to
|
|
120
|
+
* `news:nyt-world`, `research:wikidata:otter` to `research:wikidata`, and the
|
|
121
|
+
* research lane's `research:otter@2` to `research`. */
|
|
122
|
+
function publicationSourceLabel(rest) {
|
|
123
|
+
if (rest.startsWith("news:")) return publicationKeyOf(rest);
|
|
124
|
+
const { pack } = researchTagToSource(rest.slice("research:".length));
|
|
125
|
+
return `research:${pack}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `teach:chat:ingest#<tag>@<ts>` — the ingest seam driving the chat teach lane
|
|
129
|
+
* as a RECOGNIZER over a document. The party asserting is the document's own
|
|
130
|
+
* publisher, which the embedded `<tag>` names, so the record lands on that
|
|
131
|
+
* publisher's Source rather than on a chat session. Without it an ingest mints
|
|
132
|
+
* a throwaway teach Source per SENTENCE, and one publication's sentences
|
|
133
|
+
* corroborate each other for free. */
|
|
134
|
+
export const INGEST_SESSION_MARKER = "ingest#";
|
|
135
|
+
|
|
136
|
+
/** The two `research:` tag shapes, both live-fetched at query time and both
|
|
137
|
+
* scored at the referenceLive prior, below every curated pack:
|
|
138
|
+
* research:<source>:<term> one KB adapter's own lookup (researchSourceTag)
|
|
139
|
+
* research:<topic>@<depth> the research lane's fan-out, which records how
|
|
140
|
+
* far it reached rather than which adapter answered
|
|
141
|
+
* The `@` is what tells them apart — a folded term never carries one. Either
|
|
142
|
+
* way the PACK names the reference work and the article segment names the page
|
|
143
|
+
* inside it, so `sourceIdFor` can key one Source per work. */
|
|
144
|
+
function researchTagToSource(rest) {
|
|
145
|
+
const at = rest.lastIndexOf("@");
|
|
146
|
+
if (at >= 0) return { kind: "referenceLive", pack: "research", article: rest.slice(0, at).trim() || "unknown" };
|
|
147
|
+
const colon = rest.indexOf(":");
|
|
148
|
+
if (colon < 0) return { kind: "referenceLive", pack: "research", article: rest.trim() || "unknown" };
|
|
149
|
+
return { kind: "referenceLive", pack: rest.slice(0, colon) || "research", article: rest.slice(colon + 1).trim() };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The publisher a chat-shaped tag stands in for, when its session slot carries
|
|
153
|
+
* the ingest marker; null for an ordinary session, which is every tag a person
|
|
154
|
+
* actually typed. One level only — an embedded chat tag is refused rather than
|
|
155
|
+
* followed, so a hand-written tag cannot nest its way anywhere. */
|
|
156
|
+
function ingestPublisherOf(chat) {
|
|
157
|
+
if (!chat.sessionId?.startsWith(INGEST_SESSION_MARKER)) return null;
|
|
158
|
+
const embedded = chat.sessionId.slice(INGEST_SESSION_MARKER.length);
|
|
159
|
+
if (embedded.startsWith("teach:") || embedded.startsWith("ace:")) return null;
|
|
160
|
+
const publisher = provenanceTagToSource(embedded);
|
|
161
|
+
return publisher ? { ...publisher, ...(chat.createdAt ? { createdAt: chat.createdAt } : {}) } : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
110
164
|
export function provenanceTagToSource(tag) {
|
|
111
165
|
const t = String(tag || "").trim();
|
|
112
166
|
if (!t) return null;
|
|
@@ -119,17 +173,7 @@ export function provenanceTagToSource(tag) {
|
|
|
119
173
|
const pack = rest.slice(0, colon) || "unknown";
|
|
120
174
|
return { kind: referenceKindFor(pack), pack, article: rest.slice(colon + 1) };
|
|
121
175
|
}
|
|
122
|
-
|
|
123
|
-
// loads. Live-fetched at query time like the wikipedia-live pack, so it
|
|
124
|
-
// scores at the same referenceLive prior, below every curated pack. Parsed
|
|
125
|
-
// from the FULL tag (a topic may contain spaces); the depth segment records
|
|
126
|
-
// how far the fan-out reached and is not part of the Source identity.
|
|
127
|
-
if (t.startsWith("research:")) {
|
|
128
|
-
const rest = t.slice("research:".length);
|
|
129
|
-
const at = rest.lastIndexOf("@");
|
|
130
|
-
const topic = (at >= 0 ? rest.slice(0, at) : rest).trim();
|
|
131
|
-
return { kind: "referenceLive", pack: "research", article: topic || "unknown" };
|
|
132
|
-
}
|
|
176
|
+
if (t.startsWith("research:")) return researchTagToSource(t.slice("research:".length));
|
|
133
177
|
const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
|
|
134
178
|
if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
|
|
135
179
|
if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
|
|
@@ -148,7 +192,10 @@ export function provenanceTagToSource(tag) {
|
|
|
148
192
|
// `mud:` prefix so `src:corpus:mud:<character>` can never collide with a
|
|
149
193
|
// `world:<name>` Source that happens to share the literal name.
|
|
150
194
|
if (head.startsWith("mud:")) return { kind: "corpus", name: `mud:${head.slice("mud:".length).split(":")[0] || "unknown"}` };
|
|
151
|
-
if (head.startsWith("ace:"))
|
|
195
|
+
if (head.startsWith("ace:")) {
|
|
196
|
+
const chat = parseChatTagRest(head.slice("ace:".length));
|
|
197
|
+
return ingestPublisherOf(chat) || { kind: "operator", ...chat };
|
|
198
|
+
}
|
|
152
199
|
if (head.startsWith("teach:")) {
|
|
153
200
|
const rest = head.slice("teach:".length);
|
|
154
201
|
// teach:peer:<name>#node:<id>@<ts> — a peer's own relabeled tag off the
|
|
@@ -156,7 +203,8 @@ export function provenanceTagToSource(tag) {
|
|
|
156
203
|
const peerNode = parsePeerNodeTagRest(rest);
|
|
157
204
|
if (peerNode) return peerNode;
|
|
158
205
|
// the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
|
|
159
|
-
|
|
206
|
+
const chat = parseChatTagRest(rest);
|
|
207
|
+
return ingestPublisherOf(chat) || { kind: "teach", ...chat };
|
|
160
208
|
}
|
|
161
209
|
if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
|
|
162
210
|
if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
|
|
@@ -172,10 +220,23 @@ export function provenanceTagToSource(tag) {
|
|
|
172
220
|
// the bare extracted: fallback below, which still catches every other
|
|
173
221
|
// extracted: caller unchanged. A bare news:<sourceId>@<itemId> tag (a
|
|
174
222
|
// future caller that writes it directly, with no extracted: wrapper)
|
|
175
|
-
// scores the same way.
|
|
176
|
-
|
|
177
|
-
if (head.startsWith("news:")) return { kind: "web", url: head };
|
|
178
|
-
if (head.startsWith("
|
|
223
|
+
// scores the same way. One Source per FEED: the item id rides the tag for
|
|
224
|
+
// audit and stays out of the identity.
|
|
225
|
+
if (head.startsWith("extracted:news:")) return { kind: "web", url: publicationKeyOf(head.slice("extracted:".length)) };
|
|
226
|
+
if (head.startsWith("news:")) return { kind: "web", url: publicationKeyOf(head) };
|
|
227
|
+
// The same wrapper over a KB lookup's own tag: the reference work that
|
|
228
|
+
// answered is the asserting party, not the ingest run that read it.
|
|
229
|
+
if (head.startsWith("extracted:research:")) return researchTagToSource(head.slice("extracted:research:".length));
|
|
230
|
+
// The fuzzy tier keeps a Source of its OWN — a candidate the strict
|
|
231
|
+
// recognizer skipped must never corroborate a curated fact — but one per
|
|
232
|
+
// publication, not one per article.
|
|
233
|
+
if (head.startsWith("optimistic-extract:")) {
|
|
234
|
+
const rest = head.slice("optimistic-extract:".length);
|
|
235
|
+
const publication = rest.startsWith("news:") || rest.startsWith("research:")
|
|
236
|
+
? publicationSourceLabel(rest)
|
|
237
|
+
: rest;
|
|
238
|
+
return { kind: "optimisticExtract", name: publication || "unknown" };
|
|
239
|
+
}
|
|
179
240
|
if (head.startsWith("extracted:")) return { kind: "extracted", name: head.slice("extracted:".length) || "unknown" };
|
|
180
241
|
if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
|
|
181
242
|
if (head.startsWith("chat:") || head.startsWith("session:") || head.startsWith("operator")) return { kind: "operator" };
|