@clien-ai/mcp 0.10.8 → 0.11.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/CHANGELOG.md +102 -0
- package/README.md +8 -3
- package/dist/tools/collections.js +4 -1
- package/dist/tools/collections.js.map +1 -1
- package/dist/tools/market-sizing-proof.js +69 -4
- package/dist/tools/market-sizing-proof.js.map +1 -1
- package/dist/tools/personas.js +12 -3
- package/dist/tools/personas.js.map +1 -1
- package/dist/tools/publication-date-display.js +30 -0
- package/dist/tools/publication-date-display.js.map +1 -0
- package/dist/tools/receipt-children.js +127 -0
- package/dist/tools/receipt-children.js.map +1 -0
- package/dist/tools/registry.js +36 -4
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/render-safety.js +80 -0
- package/dist/tools/render-safety.js.map +1 -1
- package/dist/tools/report-digest.js +446 -18
- package/dist/tools/report-digest.js.map +1 -1
- package/dist/tools/scoped-research.js +32 -9
- package/dist/tools/scoped-research.js.map +1 -1
- package/dist/types/report.js +314 -16
- package/dist/types/report.js.map +1 -1
- package/package.json +1 -1
|
@@ -46,8 +46,9 @@
|
|
|
46
46
|
* moves agent-authored text below that separator breaks the digest's own authority
|
|
47
47
|
* rule, which a reader applies positionally.
|
|
48
48
|
*/
|
|
49
|
-
import { collapseWhitespace, safeInline, safeId, clipReceiptQuote, RECEIPT_QUOTE_MAX, } from './render-safety.js';
|
|
49
|
+
import { collapseWhitespace, safeInline, safeId, clipReceiptQuote, containsSpan, hasVisibleContent, RECEIPT_QUOTE_MAX, } from './render-safety.js';
|
|
50
50
|
import { canonicalContentType, contentTypeDeviationLine, contentTypeProvenanceLine, } from './content-type-display.js';
|
|
51
|
+
import { resolveSourceReceipt } from './receipt-children.js';
|
|
51
52
|
import { resolveSupportedMarketSizing } from './market-sizing-proof.js';
|
|
52
53
|
/**
|
|
53
54
|
* Max claims rendered per spine. Above this the digest states how many were
|
|
@@ -184,6 +185,32 @@ const UNSOURCED_LABEL = 'unsourced';
|
|
|
184
185
|
const HYPOTHESIS_LABEL = 'hypothesis';
|
|
185
186
|
const SCOPE_CAVEAT_LABEL = '⚠️ scope caveat — persona support from OUTSIDE its credibility domain; directional, NOT grounded';
|
|
186
187
|
const EVIDENCE_READING_LABEL = 'rests on';
|
|
188
|
+
/**
|
|
189
|
+
* The marker on a receipt-CHILD line, and the pool sentence that explains it (FUL-685 / T8).
|
|
190
|
+
*
|
|
191
|
+
* ⚠️ IT HAS TO BE VISUALLY SUBORDINATE, for the same reason the pool prints one row per source
|
|
192
|
+
* parent: a child line that opened with `- ` would read as another cached discussion, which is
|
|
193
|
+
* exactly the breadth inflation D13 refuses. `↳` under an existing row reads as "inside this
|
|
194
|
+
* one" and nothing else.
|
|
195
|
+
*
|
|
196
|
+
* ⚠️ AND THE WORD IS `excerpt`, NOT `quote`. T1b (FUL-697) measured the provider transforming
|
|
197
|
+
* the text it returns — deleting a paragraph break, replacing an author's hyperlink with the
|
|
198
|
+
* literal token `URL` — on the rail these children come from. Calling this a speaker's exact
|
|
199
|
+
* words would be a false claim on the one surface whose entire purpose is that the product does
|
|
200
|
+
* not make those. The sentence renders only when a row actually has a child, so a report with
|
|
201
|
+
* no multi-excerpt source is byte-identical to what it rendered before.
|
|
202
|
+
*/
|
|
203
|
+
const RECEIPT_CHILD_MARKER = '↳';
|
|
204
|
+
/**
|
|
205
|
+
* The spine's half of the same fact. One discussion can legitimately yield several excerpts, so
|
|
206
|
+
* a claim that addresses one carries BOTH ids — `sourceId` for the discussion, `receiptId` for
|
|
207
|
+
* the excerpt inside it — and the pool row's matching `↳` line is the text it was checked
|
|
208
|
+
* against. An unresolvable pair is never softened into the parent: it loses the receipt.
|
|
209
|
+
*/
|
|
210
|
+
const RECEIPT_CHILD_SPINE_NOTE = 'A claim carrying a second id after `·` addresses ONE excerpt inside that discussion — match it ' +
|
|
211
|
+
'to the `↳` line of the same id in the receipt pool below, not to the row\'s first excerpt.';
|
|
212
|
+
const RECEIPT_CHILD_POOL_NOTE = 'A `↳` line is the bounded EXCERPT one claim\'s `receiptId` addresses inside that discussion — ' +
|
|
213
|
+
'an excerpt, not a speaker\'s exact words, and the claim line above names the id it points at';
|
|
187
214
|
// ---------------------------------------------------------------------------
|
|
188
215
|
// Defensive accessors — every one of these answers "or nothing" rather than throwing
|
|
189
216
|
// ---------------------------------------------------------------------------
|
|
@@ -221,6 +248,25 @@ function renderId(raw, absent = '(no id)') {
|
|
|
221
248
|
function num(value) {
|
|
222
249
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
223
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Acceptance is a count, not merely a finite number (FUL-740).
|
|
253
|
+
*
|
|
254
|
+
* Classify before interpreting so an invalid counter can never reach a comparison that reads it
|
|
255
|
+
* as evidence or absence. `Number.isInteger` rejects non-numbers, NaN, Infinity and fractions;
|
|
256
|
+
* the remaining check rejects negative integers. `null` is treated like an omitted field because
|
|
257
|
+
* both mean the producer supplied no usable counter; either way, only `valid` carries a number
|
|
258
|
+
* downstream.
|
|
259
|
+
*/
|
|
260
|
+
function readAcceptanceCounter(value) {
|
|
261
|
+
if (value === undefined || value === null)
|
|
262
|
+
return { kind: 'absent' };
|
|
263
|
+
if (!Number.isInteger(value))
|
|
264
|
+
return { kind: 'invalid' };
|
|
265
|
+
const integer = value;
|
|
266
|
+
if (integer < 0)
|
|
267
|
+
return { kind: 'invalid' };
|
|
268
|
+
return { kind: 'valid', value: integer };
|
|
269
|
+
}
|
|
224
270
|
/**
|
|
225
271
|
* TRI-STATE boolean read: `true`, `false`, or `null` for "absent or unreadable".
|
|
226
272
|
*
|
|
@@ -272,7 +318,27 @@ function safeReceiptUrl(raw) {
|
|
|
272
318
|
return null;
|
|
273
319
|
}
|
|
274
320
|
}
|
|
275
|
-
/**
|
|
321
|
+
/**
|
|
322
|
+
* Resolve the single, persona-owned presentation grant behind a GROUNDED claim.
|
|
323
|
+
*
|
|
324
|
+
* ⚠️ FUL-711 — TWO REFERENCES, AND THEY MUST AGREE. `sourceId` locates the source parent
|
|
325
|
+
* positionally; an optional `receiptId` locates the exact excerpt CHILD inside it. The three
|
|
326
|
+
* outcomes are deliberately not two:
|
|
327
|
+
*
|
|
328
|
+
* - **no `receiptId` key (or raw `undefined`)** — the legacy path. Resolve the parent, render
|
|
329
|
+
* its single cached excerpt, exactly as before this function learned about children.
|
|
330
|
+
* - **`receiptId` resolves AND the child carries the claim's `quoteSpan`** — the claim
|
|
331
|
+
* addresses that child, and the child's own text is what `clipReceiptQuote` gets as its
|
|
332
|
+
* haystack. Both halves, or neither: FUL-728 added the span check because resolving to a
|
|
333
|
+
* child that does not contain the span is FUL-711's defect with a content-addressed id on
|
|
334
|
+
* it (see the call site).
|
|
335
|
+
* - **`receiptId` present but unresolvable** — `null`, malformed, dangling, cross-source,
|
|
336
|
+
* duplicate, or a parent with no children at all: **return `null` and fail closed.** Our
|
|
337
|
+
* producer omits this optional key, so an explicit `null` has unknown provenance and cannot
|
|
338
|
+
* claim the legacy parent's quote as grounded evidence. Falling back would print a 200-char
|
|
339
|
+
* clip of excerpt 1 under a GROUNDED badge for a claim grounded on excerpt 2 — precisely the
|
|
340
|
+
* defect this resolution exists to remove, restored as an error path.
|
|
341
|
+
*/
|
|
276
342
|
function resolvePersonaReceipt(rawClaim, personas) {
|
|
277
343
|
const claim = asRecord(rawClaim);
|
|
278
344
|
if (str(claim?.state) !== 'GROUNDED')
|
|
@@ -288,11 +354,71 @@ function resolvePersonaReceipt(rawClaim, personas) {
|
|
|
288
354
|
const href = safeReceiptUrl(source?.url);
|
|
289
355
|
if (!source || !href)
|
|
290
356
|
return null;
|
|
357
|
+
// Branch on the RAW value, not on the resolved one: only `undefined` is "legacy claim, nothing
|
|
358
|
+
// to resolve" and every present value — including `null` — is a promise this parent must keep.
|
|
359
|
+
const claimedReceiptId = claim?.receiptId;
|
|
360
|
+
let child = null;
|
|
361
|
+
if (claimedReceiptId !== undefined) {
|
|
362
|
+
child = resolveSourceReceipt(source.receipts, claimedReceiptId) ?? null;
|
|
363
|
+
if (!child)
|
|
364
|
+
return null;
|
|
365
|
+
// ⚠️ RESOLVING THE ID IS NOT CHECKING THE SPAN, AND FUL-711'S DEFECT SURVIVES THE GAP
|
|
366
|
+
// (FUL-728). A `receiptId` that is well-formed, lives under the RIGHT parent, and is unique
|
|
367
|
+
// there resolves cleanly — while the claim's `quoteSpan` sits in a DIFFERENT child of that
|
|
368
|
+
// same parent. Nothing above notices: the id was never asked to agree with the span, only to
|
|
369
|
+
// exist. `clipReceiptQuote` then finds no span in the child it was handed, takes the head
|
|
370
|
+
// clip, and the digest prints `[GROUNDED]` beside an excerpt that does not contain the
|
|
371
|
+
// sentence which earned the badge — FUL-711 resolved to the wrong child instead of
|
|
372
|
+
// positionally, which is the same lie with a content-addressed id on it. D12 asks the
|
|
373
|
+
// selected receipt to CONTAIN the exact span, so ask it here.
|
|
374
|
+
//
|
|
375
|
+
// ⚠️ THIS IS A DIFFERENT AXIS FROM THE `> 1` BOUND BELOW, and neither substitutes for the
|
|
376
|
+
// other. That one asks whether a claim named a child at all; this one asks whether the child
|
|
377
|
+
// it named is the right one. Widening that bound would break the single-child byte-identity
|
|
378
|
+
// guarantee it exists to hold; this check cannot, because it lives entirely inside the branch
|
|
379
|
+
// a `receiptId` opens — a legacy claim carries none and never reaches it.
|
|
380
|
+
//
|
|
381
|
+
// ⚠️ NO `quoteSpan` STILL GRANTS. There is no span to contradict the child, and refusing on
|
|
382
|
+
// absence would be a different rule — "a Reddit claim must carry a span" — which belongs to
|
|
383
|
+
// the producer and to T5's schema, not to a renderer inferring it. `containsSpan` uses the
|
|
384
|
+
// renderer's OWN lookup, so a span this package could not locate can never earn a badge here
|
|
385
|
+
// and then quietly render as an unwindowed head clip.
|
|
386
|
+
//
|
|
387
|
+
// ⚠️ BRANCH ON THE RAW VALUE, exactly as `receiptId` does two lines up (#1059 review). `str()`
|
|
388
|
+
// answers `null` for a NUMBER, an OBJECT and `''` alike, so reading through it would file a
|
|
389
|
+
// present-but-malformed span under "legacy claim, nothing to check" and grant. Those are
|
|
390
|
+
// different facts: absence is a claim that never promised a span, while a present one is a
|
|
391
|
+
// promise this child has to keep, and a producer that emitted `42` or `''` there has told us
|
|
392
|
+
// nothing about which sentence earned the badge. Fail closed on the promise it cannot keep.
|
|
393
|
+
const rawSpan = claim?.quoteSpan;
|
|
394
|
+
if (rawSpan !== undefined && rawSpan !== null) {
|
|
395
|
+
const span = str(rawSpan);
|
|
396
|
+
if (span === null || !containsSpan(child.excerpt, span))
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else if (asArray(source.receipts).length > 1) {
|
|
401
|
+
// ⚠️ ABSENT `receiptId` ON A parent carrying SEVERAL children is not a legacy claim — it is a
|
|
402
|
+
// producer that failed to name one, and it is the only case that reaches the fallback this
|
|
403
|
+
// function's docblock forbids. `quote` holds excerpt 1 (D13: one staging row per permalink),
|
|
404
|
+
// so returning the parent prints a clip of excerpt 1 under a GROUNDED badge for a claim whose
|
|
405
|
+
// span may live in excerpt 2 — FUL-711's exact defect, restored as an error path that fires
|
|
406
|
+
// only on malformed input nobody is watching.
|
|
407
|
+
//
|
|
408
|
+
// ⚠️ THE BOUND IS `> 1`, NOT `> 0`, AND THAT IS D15's LINE — "multi-receipt evidence" needs an
|
|
409
|
+
// addressed child; "legacy single-receipt" keeps positional behaviour. With exactly ONE child
|
|
410
|
+
// the parent row and that child carry the same text BY CONSTRUCTION, so resolving positionally
|
|
411
|
+
// cannot show a span-free excerpt and the legacy byte-identity guarantee holds. Widening this
|
|
412
|
+
// to `> 0` breaks that guarantee for one-child sources, which is a real regression: an
|
|
413
|
+
// additive field would start changing what a legacy reader sees.
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
291
416
|
return {
|
|
292
417
|
sourceId: `RCP-p${personaIndex}-s${sourceIndex}`,
|
|
293
418
|
href,
|
|
294
419
|
quote: collapseWhitespace(str(source.quote) ?? ''),
|
|
295
420
|
source,
|
|
421
|
+
child,
|
|
296
422
|
};
|
|
297
423
|
}
|
|
298
424
|
function resolvePersonaRows(reportData) {
|
|
@@ -369,7 +495,13 @@ function renderPersonaClaim(raw, resolvedReceipt) {
|
|
|
369
495
|
// why a pointer on a non-GROUNDED claim must never render as a receipt.
|
|
370
496
|
let provenance = '';
|
|
371
497
|
if (resolvedReceipt) {
|
|
372
|
-
|
|
498
|
+
// FUL-685: when the claim addressed a receipt CHILD, the arrow carries both halves of the
|
|
499
|
+
// pair. Without the child id a reader looking at two claims on one `RCP-p0-s0` row cannot
|
|
500
|
+
// tell which `↳` excerpt grounds which claim — the same "wrong excerpt under a GROUNDED
|
|
501
|
+
// badge" confusion this change removes, relocated one line down. `safeId` because it is an
|
|
502
|
+
// identifier: exact or nothing.
|
|
503
|
+
const childId = resolvedReceipt.child ? safeId(resolvedReceipt.child.receiptId) : null;
|
|
504
|
+
provenance = ` ← ${resolvedReceipt.sourceId}${childId ? ` · ${childId}` : ''}`;
|
|
373
505
|
}
|
|
374
506
|
else if (rawState === 'GROUNDED') {
|
|
375
507
|
provenance = ' · Stored grade: GROUNDED — receipt unavailable; do not cite';
|
|
@@ -387,7 +519,8 @@ function renderPersonaClaim(raw, resolvedReceipt) {
|
|
|
387
519
|
* A stored GROUNDED grade and a model-attestation marker are both untrusted report JSON, not proof
|
|
388
520
|
* by themselves. Either earns its presentation only when its canonical pointer indexes the exact
|
|
389
521
|
* `reportEvidence` pool and the target has a non-credentialed HTTP(S) destination. An attestation
|
|
390
|
-
* additionally needs
|
|
522
|
+
* additionally needs a captured window with something VISIBLE in it — not merely a nonblank
|
|
523
|
+
* string, which U+200B and the C0 controls satisfy. Keeping this as one resolver lets the row,
|
|
391
524
|
* tally, pointer and window consume the same answer instead of minting orphan citations.
|
|
392
525
|
*/
|
|
393
526
|
function resolveReportReceipt(rawClaim, reportEvidence) {
|
|
@@ -405,7 +538,13 @@ function resolveReportReceipt(rawClaim, reportEvidence) {
|
|
|
405
538
|
const attestation = asRecord(claim?.attestation);
|
|
406
539
|
sourceId = typeof attestation?.sourceId === 'string' ? attestation.sourceId.trim() : '';
|
|
407
540
|
quote = typeof attestation?.quote === 'string' ? collapseWhitespace(attestation.quote) : '';
|
|
408
|
-
|
|
541
|
+
// ⚠️ NONBLANK IS NOT THE SAME TEST AS "HAS A WINDOW" (#1059 review, same defect as FUL-728
|
|
542
|
+
// item 3). `collapseWhitespace` only collapses `\s`, which covers none of U+200B, U+2060,
|
|
543
|
+
// U+00AD or the C0 controls — so an attestation window built from those survives this as a
|
|
544
|
+
// non-empty string, clears the guard, and the claim earns `cited → RRCP-s0` plus an
|
|
545
|
+
// `Attested window: ""` line. A citation marker over a window nobody can read is the
|
|
546
|
+
// presentation this resolver exists to withhold.
|
|
547
|
+
if (!hasVisibleContent(quote))
|
|
409
548
|
return null;
|
|
410
549
|
}
|
|
411
550
|
else {
|
|
@@ -523,9 +662,14 @@ function renderPersonaSpine(reportData, channel) {
|
|
|
523
662
|
const tally = tallyPersonaStates(rows);
|
|
524
663
|
const shown = rows.slice(0, CLAIM_RENDER_CAP);
|
|
525
664
|
const lines = shown.map(({ claim, receipt }) => renderPersonaClaim(claim, receipt));
|
|
665
|
+
// FUL-685: only say the pair exists when a rendered claim actually carries one. A report with
|
|
666
|
+
// no multi-excerpt source renders this header byte-identically to before.
|
|
667
|
+
const anyChild = shown.some(({ receipt }) => receipt?.child);
|
|
526
668
|
return (`### Persona claim spine — ${tallyLine(tally, rows.length)}\n` +
|
|
527
669
|
'A GROUNDED claim\'s receipt id (`RCP-p{i}-s{j}`) indexes `personas[i].sources[j]` — the cached ' +
|
|
528
|
-
'forum post its quote span was code-verified against.
|
|
670
|
+
'forum post its quote span was code-verified against. ' +
|
|
671
|
+
(anyChild ? `${RECEIPT_CHILD_SPINE_NOTE} ` : '') +
|
|
672
|
+
'A stored GROUNDED grade whose canonical, ' +
|
|
529
673
|
'persona-owned safe receipt is unavailable is rendered and counted NO_RECEIPT. NO_RECEIPT can mean a bad claim OR merely ' +
|
|
530
674
|
'sparse evidence: check that persona\'s `insufficientEvidence` / `sourcesFound` below before ' +
|
|
531
675
|
'discounting it. Anything not GROUNDED is unverified.\n' +
|
|
@@ -582,6 +726,25 @@ function renderReportSpine(reportData, channel) {
|
|
|
582
726
|
* has no visible badge, so windowing a quote to support it would spend the excerpt
|
|
583
727
|
* on a claim the agent cannot see, at the cost of one it can.
|
|
584
728
|
*/
|
|
729
|
+
/**
|
|
730
|
+
* The bucket a claim's verified span belongs to.
|
|
731
|
+
*
|
|
732
|
+
* ⚠️ FUL-711 — KEYED BY THE HAYSTACK, NOT BY THE SOURCE. Before receipt children there was one
|
|
733
|
+
* text per source and `sourceId` was both. Now a source can carry several excerpts, and a span
|
|
734
|
+
* verified inside excerpt 2 is not findable in excerpt 1 — so windowing the parent by it would
|
|
735
|
+
* locate nothing and silently fall back to the head clip. One bucket per (parent, child), with
|
|
736
|
+
* the childless bucket keyed by the empty string, keeps every span pointed at the text it is
|
|
737
|
+
* actually in. A NUL (`U+0000`) cannot appear in either id, so the two halves cannot alias.
|
|
738
|
+
*
|
|
739
|
+
* ⚠️ THE SEPARATOR IS WRITTEN `\u0000`, NEVER AS THE RAW BYTE. A literal NUL anywhere in a
|
|
740
|
+
* `.ts` file makes `file` classify it as `data` and makes grep and ripgrep skip it with a bare
|
|
741
|
+
* `Binary file matches` — which, on the digest, silently removes the repo's most trust-critical
|
|
742
|
+
* renderer from every search an agent runs here. The escape is the same byte at runtime.
|
|
743
|
+
* Enforced by `__tests__/integration/workflows/source-files-stay-greppable.test.ts`.
|
|
744
|
+
*/
|
|
745
|
+
function receiptBucketKey(sourceId, receiptId) {
|
|
746
|
+
return `${sourceId}\u0000${receiptId ?? ''}`;
|
|
747
|
+
}
|
|
585
748
|
function collectVerifiedSpans(rows) {
|
|
586
749
|
const spans = new Map();
|
|
587
750
|
for (const { claim: raw, receipt } of rows.slice(0, CLAIM_RENDER_CAP)) {
|
|
@@ -591,11 +754,12 @@ function collectVerifiedSpans(rows) {
|
|
|
591
754
|
const span = str(claim.quoteSpan);
|
|
592
755
|
if (!span)
|
|
593
756
|
continue;
|
|
594
|
-
const
|
|
757
|
+
const key = receiptBucketKey(receipt.sourceId, receipt.child?.receiptId ?? null);
|
|
758
|
+
const existing = spans.get(key);
|
|
595
759
|
if (existing)
|
|
596
760
|
existing.push(span);
|
|
597
761
|
else
|
|
598
|
-
spans.set(
|
|
762
|
+
spans.set(key, [span]);
|
|
599
763
|
}
|
|
600
764
|
return spans;
|
|
601
765
|
}
|
|
@@ -622,18 +786,36 @@ function collectVerifiedSpans(rows) {
|
|
|
622
786
|
* pages, whose "quote" would be a page excerpt chosen at fetch time rather than a
|
|
623
787
|
* human's own words, and the pool already carries the one thing a reader needs
|
|
624
788
|
* from them — `publishedDate`, the figure's actual recency (FUL-148).
|
|
789
|
+
*
|
|
790
|
+
* FUL-685 (T8 / D13/D15), spec in FUL-711: a source whose ONE url yielded several bounded
|
|
791
|
+
* excerpts gets ONE row here, still — receipt count must never inflate source or discussion
|
|
792
|
+
* breadth — with an indented `↳` line per excerpt a rendered claim actually ADDRESSES. Not per
|
|
793
|
+
* stored excerpt: printing the whole child set under a row would make one discussion look like
|
|
794
|
+
* several, which is the inflation D13 exists to prevent, and would spend the digest budget on
|
|
795
|
+
* text no claim points at.
|
|
625
796
|
*/
|
|
626
797
|
function renderReceiptPools(reportData, channel) {
|
|
627
798
|
const reportEvidence = asArray(reportData.reportEvidence);
|
|
628
799
|
const personaRows = resolvePersonaRows(reportData).slice(0, CLAIM_RENDER_CAP);
|
|
629
800
|
const spansByReceipt = collectVerifiedSpans(personaRows);
|
|
801
|
+
/**
|
|
802
|
+
* One entry per source PARENT, carrying the receipt children the rendered claims addressed.
|
|
803
|
+
* A `Map` keyed by child id so two claims on the same excerpt collapse to one `↳` line.
|
|
804
|
+
*/
|
|
630
805
|
const personaReceiptById = new Map();
|
|
631
806
|
for (const { receipt } of personaRows) {
|
|
632
|
-
if (receipt)
|
|
633
|
-
|
|
807
|
+
if (!receipt)
|
|
808
|
+
continue;
|
|
809
|
+
let row = personaReceiptById.get(receipt.sourceId);
|
|
810
|
+
if (!row) {
|
|
811
|
+
row = { receipt, children: new Map() };
|
|
812
|
+
personaReceiptById.set(receipt.sourceId, row);
|
|
813
|
+
}
|
|
814
|
+
if (receipt.child)
|
|
815
|
+
row.children.set(receipt.child.receiptId, receipt.child);
|
|
634
816
|
}
|
|
635
817
|
const personaReceipts = [];
|
|
636
|
-
for (const [receiptId, receipt] of personaReceiptById) {
|
|
818
|
+
for (const [receiptId, { receipt, children }] of personaReceiptById) {
|
|
637
819
|
const source = receipt.source;
|
|
638
820
|
// FUL-253: the quote below was already flattened — these two were not, and
|
|
639
821
|
// they sit on the SAME `- RCP-…` line, so a newline in either forges the
|
|
@@ -647,17 +829,59 @@ function renderReceiptPools(reportData, channel) {
|
|
|
647
829
|
// `collapseWhitespace` is a FORGERY GUARD, not formatting: this pool is a
|
|
648
830
|
// list of `- RCP-i-j — …` lines, and a quote containing a newline plus a
|
|
649
831
|
// convincing `- RCP-` prefix would add an entry pointing at a source nobody
|
|
650
|
-
// retrieved.
|
|
651
|
-
//
|
|
832
|
+
// retrieved.
|
|
833
|
+
//
|
|
834
|
+
// ⚠️ WHETHER TO PRINT THE LINE AT ALL IS A DIFFERENT QUESTION, AND `hasVisibleContent` IS THE
|
|
835
|
+
// ONE THAT ANSWERS IT (#1059 review, same defect as FUL-728 item 3). A whitespace-only quote
|
|
836
|
+
// collapses to '' and correctly renders as a bare pointer — "the source said nothing" — but
|
|
837
|
+
// a quote of U+200B or U+0000 survives the collapse non-empty, and this line then prints `""`
|
|
838
|
+
// under a receipt GROUNDED claims point at. The forgery guard stays on the flattening; the
|
|
839
|
+
// emptiness question is asked of the visible characters.
|
|
652
840
|
//
|
|
653
841
|
// FUL-252: WHICH `RECEIPT_QUOTE_MAX` characters is chosen by the spans of the
|
|
654
842
|
// GROUNDED claims pointing at THIS receipt, not by the quote's head. Passing
|
|
655
843
|
// the receipt's own id is the whole wiring — `RCP-p{i}-s{j}` is the pointer
|
|
656
844
|
// `renderPersonaClaim` prints, so the two sides of the arrow are built from
|
|
657
845
|
// the same expression and cannot drift into windowing the wrong receipt.
|
|
846
|
+
//
|
|
847
|
+
// ⚠️ FUL-685: the parent bucket is now the CHILDLESS one. A span verified inside excerpt 2
|
|
848
|
+
// is not findable in excerpt 1, so windowing this line by it would locate nothing and fall
|
|
849
|
+
// back to the head clip while looking like a window. Legacy claims carry no `receiptId`, so
|
|
850
|
+
// for every pre-existing report every span is still in this bucket and this line is
|
|
851
|
+
// byte-identical to what it rendered before.
|
|
658
852
|
const quote = receipt.quote;
|
|
659
|
-
const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptId) ?? [], RECEIPT_QUOTE_MAX);
|
|
660
|
-
const
|
|
853
|
+
const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptBucketKey(receiptId, null)) ?? [], RECEIPT_QUOTE_MAX);
|
|
854
|
+
const topic = safeInline(source?.topic, 40);
|
|
855
|
+
const topicLine = topic ? `\n On ${topic}` : '';
|
|
856
|
+
const quoteLine = hasVisibleContent(quote) ? `\n "${clipped}"` : '';
|
|
857
|
+
// The addressed receipt CHILDREN, one line each, sorted by receipt id.
|
|
858
|
+
//
|
|
859
|
+
// ⚠️ THE PARENT LINE STAYS, EVEN WHEN A CHILD REPEATS IT. The parent's `quote` IS the first
|
|
860
|
+
// excerpt (that is what `admitRedditPersonaSources` writes), so a claim addressing child 1
|
|
861
|
+
// prints the same sentence twice. That redundancy is deliberate and cheaper than the
|
|
862
|
+
// alternatives: suppressing the parent line whenever every claim addressed a child means a
|
|
863
|
+
// row can render with no excerpt at all on any path that miscounts, and suppressing a `↳`
|
|
864
|
+
// line that merely duplicates the parent breaks the header's own instruction — a claim's id
|
|
865
|
+
// would have no line to match, which is the mapping this change exists to establish.
|
|
866
|
+
//
|
|
867
|
+
// ⚠️ SORTED BY ID, NOT BY CLAIM ORDER. The id is content-addressed — `deriveReceiptId`
|
|
868
|
+
// hashes (normalized url, exact excerpt) — so sorting on it is stable across retry, merge,
|
|
869
|
+
// reopen and promotion, and matches the order the producer emits children in. Ordering by
|
|
870
|
+
// first-claim-seen would instead make the pool's shape a function of how the synthesiser
|
|
871
|
+
// happened to sequence its claims.
|
|
872
|
+
//
|
|
873
|
+
// `safeId` rather than `safeInline`: this is an IDENTIFIER, and a repaired id is a wrong id
|
|
874
|
+
// that still reads as one. It cannot fail in practice — nothing reaches here without
|
|
875
|
+
// matching `rr{n}:{16 hex}` — but exact-or-nothing is the rule for every id in this package
|
|
876
|
+
// and a silently clipped one would be a pointer to a child that does not exist.
|
|
877
|
+
const childLines = [...children.values()]
|
|
878
|
+
.sort((a, b) => (a.receiptId < b.receiptId ? -1 : a.receiptId > b.receiptId ? 1 : 0))
|
|
879
|
+
.map((child) => {
|
|
880
|
+
const childId = safeId(child.receiptId) ?? '(unrenderable receipt id)';
|
|
881
|
+
const clippedChild = clipReceiptQuote(child.excerpt, spansByReceipt.get(receiptBucketKey(receiptId, child.receiptId)) ?? [], RECEIPT_QUOTE_MAX);
|
|
882
|
+
return `\n ${RECEIPT_CHILD_MARKER} ${childId} — "${clippedChild}"`;
|
|
883
|
+
})
|
|
884
|
+
.join('');
|
|
661
885
|
// FUL-560: the source type, stated ONCE for the pool (in the header below) and inline only
|
|
662
886
|
// on a row that breaks the stated rule. This pool is the one place in the package where the
|
|
663
887
|
// type is a near-constant: `stampEvidenceMetadata` (agent-side) prunes every non-`user_voice`
|
|
@@ -671,8 +895,9 @@ function renderReceiptPools(reportData, channel) {
|
|
|
671
895
|
// would be exactly the "silence reads as reassurance" fail-open this change exists to close.
|
|
672
896
|
const personaType = canonicalContentType(source?.contentType);
|
|
673
897
|
const deviationLine = personaType === 'user_voice' ? '' : contentTypeDeviationLine(source?.contentType);
|
|
674
|
-
personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${quoteLine}${deviationLine}`);
|
|
898
|
+
personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${topicLine}${quoteLine}${childLines}${deviationLine}`);
|
|
675
899
|
}
|
|
900
|
+
const anyReceiptChildren = [...personaReceiptById.values()].some((row) => row.children.size > 0);
|
|
676
901
|
const evidenceReceipts = reportEvidence.map((raw, n) => {
|
|
677
902
|
const source = asRecord(raw);
|
|
678
903
|
// FUL-253: every field on this row is scraped-page metadata, and the row is
|
|
@@ -711,7 +936,9 @@ function renderReceiptPools(reportData, channel) {
|
|
|
711
936
|
if (personaReceipts.length > 0) {
|
|
712
937
|
const shown = personaReceipts.slice(0, CLAIM_RENDER_CAP);
|
|
713
938
|
sections.push(`**Persona receipts** (${plural(personaReceipts.length, 'cached forum post')}) — safe targets of the visible \`RCP-\` pointers above. ` +
|
|
714
|
-
'Every receipt here is a first-hand `user_voice` post unless its own row says otherwise
|
|
939
|
+
'Every receipt here is a first-hand `user_voice` post unless its own row says otherwise' +
|
|
940
|
+
(anyReceiptChildren ? `. ${RECEIPT_CHILD_POOL_NOTE}` : '') +
|
|
941
|
+
':\n' +
|
|
715
942
|
shown.join('\n') +
|
|
716
943
|
capNote(shown.length, personaReceipts.length, channel, 'report_data.personas[].sources'));
|
|
717
944
|
}
|
|
@@ -1469,7 +1696,7 @@ function renderMarketSizing(reportData) {
|
|
|
1469
1696
|
return `- ${labels[scope]}: — (Not established)`;
|
|
1470
1697
|
}
|
|
1471
1698
|
const validInputs = inputs.filter((input) => input !== null);
|
|
1472
|
-
const sameBoundary = validInputs.every((input) => input.period === entry.period && input.geography === entry.geography && input.audience === entry.audience);
|
|
1699
|
+
const sameBoundary = validInputs.every((input) => input.kind === 'ratio_assumption' || (input.period === entry.period && input.geography === entry.geography && input.audience === entry.audience));
|
|
1473
1700
|
const inputValues = validInputs.map((input) => num(input.value));
|
|
1474
1701
|
const oneCurrency = validInputs.filter((input) => input.kind === 'currency' && input.currency === currency).length === 1;
|
|
1475
1702
|
const product = inputValues.every((input) => input !== null)
|
|
@@ -1483,6 +1710,204 @@ function renderMarketSizing(reportData) {
|
|
|
1483
1710
|
});
|
|
1484
1711
|
return `### TAM / SAM / SOM — typed sizing\n${lines.join('\n')}`;
|
|
1485
1712
|
}
|
|
1713
|
+
/**
|
|
1714
|
+
* The Reddit community-evidence rail's typed outcome (FUL-685 / T8, D6).
|
|
1715
|
+
*
|
|
1716
|
+
* ⚠️ SILENCE IS A STATE, AND SO IS SAYING NOTHING — they are different, and this function is
|
|
1717
|
+
* where the difference is kept. TWO cases render NOTHING at all:
|
|
1718
|
+
*
|
|
1719
|
+
* - the note is **absent or unreadable** → UNRECORDED. Every report written before the field
|
|
1720
|
+
* existed omits it, as does one served by an app deploy that predates it. Printing "Reddit
|
|
1721
|
+
* was not searched" off that silence would be inventing a fact about the run.
|
|
1722
|
+
* - the note says **`not_run`** → the rail did not dispatch, so there is no coverage claim to
|
|
1723
|
+
* make in either direction. The plan's placement table is explicit: no Reddit-specific copy
|
|
1724
|
+
* and no claim that Reddit ran; this is operator telemetry, and it lives in the ledger.
|
|
1725
|
+
*
|
|
1726
|
+
* The other four each get their own sentence, and `failed` may NEVER be relabelled
|
|
1727
|
+
* `completed_empty` (F7): a provider outage dressed up as an honest empty search is a false claim
|
|
1728
|
+
* about coverage, which is the one thing this whole digest exists not to make.
|
|
1729
|
+
*
|
|
1730
|
+
* ⚠️ COUNTS ARE TWO NUMBERS, NOT ONE. `acceptedSourceCount` is discussions and
|
|
1731
|
+
* `acceptedExcerptCount` is excerpts, because one thread legitimately yields several — collapsing
|
|
1732
|
+
* them would let receipt multiplicity inflate the breadth figure, which D12/D13 forbid.
|
|
1733
|
+
*
|
|
1734
|
+
* ⚠️ NO COST, NO LATENCY, NO REASON CODE ON A HEALTHY RUN. `costUsd`, `latencyMs`,
|
|
1735
|
+
* `requestsIssued`, `toolRequests` and the three intent counters are operator telemetry; they
|
|
1736
|
+
* change nothing an agent should do about a claim, and a digest an agent skims past is a digest it
|
|
1737
|
+
* does not read. The reason code renders only where it explains a limitation the reader has to act
|
|
1738
|
+
* on.
|
|
1739
|
+
*
|
|
1740
|
+
* ⚠️ ONE PRODUCER WRITES THIS TODAY, AND IT IS NOT THE HERO. `redditReportMethodology`
|
|
1741
|
+
* (`agent/src/scoped-research.ts`, FUL-686 / T9) stamps the summary on every scoped run whose rail
|
|
1742
|
+
* was ARMED; the hero orchestrator still DELETES the key at its enrich chokepoint, and T10
|
|
1743
|
+
* (FUL-687) owns replacing that delete with the real backfill plus the matching terminal event. So
|
|
1744
|
+
* a report reaching this renderer with no note is either a hero run or a pre-feature one — which
|
|
1745
|
+
* is exactly why UNRECORDED, and not "Reddit was not searched", is the only honest reading of
|
|
1746
|
+
* absence.
|
|
1747
|
+
*/
|
|
1748
|
+
function renderRedditRetrieval(reportData) {
|
|
1749
|
+
const note = asRecord(asRecord(reportData.methodology)?.redditRetrieval);
|
|
1750
|
+
if (!note)
|
|
1751
|
+
return '';
|
|
1752
|
+
const outcome = str(note.outcome);
|
|
1753
|
+
if (!outcome || outcome === 'not_run')
|
|
1754
|
+
return '';
|
|
1755
|
+
const reason = safeInline(note.reason, 60);
|
|
1756
|
+
const because = reason ? ` (reason code: \`${reason}\`)` : '';
|
|
1757
|
+
const sourceCounter = readAcceptanceCounter(note.acceptedSourceCount);
|
|
1758
|
+
const excerptCounter = readAcceptanceCounter(note.acceptedExcerptCount);
|
|
1759
|
+
const sources = sourceCounter.kind === 'valid' ? sourceCounter.value : null;
|
|
1760
|
+
const excerpts = excerptCounter.kind === 'valid' ? excerptCounter.value : null;
|
|
1761
|
+
const validCounters = sourceCounter.kind === 'valid' && excerptCounter.kind === 'valid'
|
|
1762
|
+
? { sources: sourceCounter.value, excerpts: excerptCounter.value }
|
|
1763
|
+
: null;
|
|
1764
|
+
// ⚠️ THE TWO COUNTERS ARE POSITIVE TOGETHER OR ZERO TOGETHER, AND A PAIR THAT DISAGREES IS A
|
|
1765
|
+
// CORRUPT NOTE (FUL-732). Both derive from the same accepted-source array and every accepted
|
|
1766
|
+
// source carries at least one excerpt, so `acceptedSourceCount: 0` beside `acceptedExcerptCount:
|
|
1767
|
+
// 7` is not a measurement this rail can produce — it is the note telling you it drifted. Reading
|
|
1768
|
+
// acceptance off whichever number happens to be positive trusts a record that has already proved
|
|
1769
|
+
// it cannot be trusted, which is what the OR below used to do.
|
|
1770
|
+
const countersContradict = validCounters !== null && validCounters.sources > 0 !== validCounters.excerpts > 0;
|
|
1771
|
+
// Counts are stated only when BOTH are readable AND they agree. "3 discussions · unknown
|
|
1772
|
+
// excerpts" reads as a measurement rather than as a gap in the record, and the outcome sentence
|
|
1773
|
+
// already carries the fact that matters; "0 accepted discussions · 7 bounded excerpts" reads as
|
|
1774
|
+
// a measurement of something impossible, which is worse. Gating it here rather than per-arm is
|
|
1775
|
+
// what stops the impossible pair reaching any outcome's wording — including the ones below that
|
|
1776
|
+
// never ask about acceptance at all.
|
|
1777
|
+
const counts = validCounters && !countersContradict
|
|
1778
|
+
? ` ${plural(validCounters.sources, 'accepted discussion')} · ` +
|
|
1779
|
+
`${plural(validCounters.excerpts, 'bounded excerpt')}.`
|
|
1780
|
+
: '';
|
|
1781
|
+
// ⚠️ THIS QUESTION IS THREE-WAY, AND THE ORCHESTRATOR HAS COLLAPSED IT TO TWO IN BOTH
|
|
1782
|
+
// DIRECTIONS ALREADY (FUL-732). Read the history before touching these three lines:
|
|
1783
|
+
//
|
|
1784
|
+
// 1. FUL-728 keyed the unknown wording on `acceptedSourceCount` ALONE, so a note carrying
|
|
1785
|
+
// seven excerpts was reported as a pass that "may have accepted none" — a FALSE NEGATIVE.
|
|
1786
|
+
// 2. The correction made acceptance an OR over the two counters, so a note reporting `0`
|
|
1787
|
+
// sources beside `7` excerpts rendered "0 accepted discussions · 7 bounded excerpts.
|
|
1788
|
+
// Accepted evidence is real." plus the pool pointer — a line that contradicts itself,
|
|
1789
|
+
// reached by trusting whichever number was positive.
|
|
1790
|
+
//
|
|
1791
|
+
// Both are the same mistake: a two-valued test over a question with three answers. The rule that
|
|
1792
|
+
// holds is the table below, and neither arm may be folded into another.
|
|
1793
|
+
//
|
|
1794
|
+
// two valid counters agree and prove acceptance → accepted evidence is real
|
|
1795
|
+
// two valid counters agree on zero → accepted none
|
|
1796
|
+
// counters contradict, or either is absent/invalid → UNKNOWN wording, and NO pool pointer
|
|
1797
|
+
//
|
|
1798
|
+
// FUL-740 closes the repeated edge left by interpreting first and guarding second. A count is
|
|
1799
|
+
// valid only when it is a finite non-negative integer; one counter never validates its partner.
|
|
1800
|
+
// That makes the invalid state unrepresentable in either assertion below instead of relying on
|
|
1801
|
+
// every comparison to remember negatives, fractions and non-finite values separately.
|
|
1802
|
+
const acceptedProven = validCounters !== null &&
|
|
1803
|
+
!countersContradict &&
|
|
1804
|
+
validCounters.sources > 0 &&
|
|
1805
|
+
validCounters.excerpts > 0;
|
|
1806
|
+
const acceptedNoneProven = validCounters !== null &&
|
|
1807
|
+
!countersContradict &&
|
|
1808
|
+
validCounters.sources === 0 &&
|
|
1809
|
+
validCounters.excerpts === 0;
|
|
1810
|
+
// ⚠️ THE POOL POINTER BELONGS ONLY TO THE OUTCOMES THAT HAVE EVIDENCE. Appending it to all
|
|
1811
|
+
// four sent a `failed` reader looking for accepted excerpts in a pool the very next section
|
|
1812
|
+
// often declares EMPTY — a pointer to nothing, in the paragraph that just said the pass did not
|
|
1813
|
+
// complete. And even on a good run it is a POINTER, not a promise: the pool renders the sources
|
|
1814
|
+
// a GROUNDED claim resolved, so an accepted excerpt no claim cites is not there. The wording
|
|
1815
|
+
// says "any excerpt a claim cites", which is what the pool actually holds.
|
|
1816
|
+
const poolPointer = ' Any accepted excerpt a claim cites appears in the persona receipt pool below like any other ' +
|
|
1817
|
+
'cached source; they are excerpts from a discussion, not a speaker\'s exact words.';
|
|
1818
|
+
// ⚠️ `completed` MEANS AT LEAST ONE SOURCE WAS ACCEPTED — that is the runtime contract, not a
|
|
1819
|
+
// convention — so `completed` beside a zero acceptance count is INCONSISTENT INPUT, not a state.
|
|
1820
|
+
// Rendering the coverage sentence for it announces a successful pass and points the reader at a
|
|
1821
|
+
// pool the very next section may declare EMPTY: the same defect the `completed_partial` branch
|
|
1822
|
+
// below already refuses, one outcome over. The honest answer to a note that contradicts itself
|
|
1823
|
+
// is the one an unrecognised outcome gets — coverage UNKNOWN.
|
|
1824
|
+
//
|
|
1825
|
+
// ⚠️ EITHER COUNTER READING ZERO IS THAT CONTRADICTION, NOT JUST `acceptedSourceCount`
|
|
1826
|
+
// (#1063 review). This branch keyed on the source count alone, on the argument that `completed`
|
|
1827
|
+
// independently asserts a source was accepted so the outcome sentence survives an
|
|
1828
|
+
// `acceptedExcerptCount: 0`. That argument uses one side of the contradiction to validate
|
|
1829
|
+
// itself: by the same invariant the contradictory pair rests on, every accepted source carries
|
|
1830
|
+
// at least one excerpt, so `excerpts === 0` PROVES `sources === 0` — the exact shape this
|
|
1831
|
+
// branch already refuses to render as a completed pass. Keeping the outcome sentence there and
|
|
1832
|
+
// dropping only the figures announced coverage from a note already known corrupt, and pointed
|
|
1833
|
+
// at the pool while doing it. On `completed` an impossible PAIR (7 / 0) is not a separate case,
|
|
1834
|
+
// because whichever valid counter reads exactly zero already contradicts the outcome.
|
|
1835
|
+
//
|
|
1836
|
+
// A `null` is UNREADABLE, not zero, and keeps the completed wording: the note omitted or
|
|
1837
|
+
// corrupted a telemetry counter, which is not the note claiming something impossible.
|
|
1838
|
+
const completedWithNoAcceptance = outcome === 'completed' &&
|
|
1839
|
+
(sources === 0 || excerpts === 0);
|
|
1840
|
+
const body = completedWithNoAcceptance
|
|
1841
|
+
? `The Reddit pass reported \`completed\` beside a ZERO acceptance count${because}, which the ` +
|
|
1842
|
+
"rail's own contract does not allow — a completed pass accepted at least one source, and " +
|
|
1843
|
+
'every accepted source carries at least one excerpt, so neither counter can read zero. The ' +
|
|
1844
|
+
'note contradicts itself, so treat Reddit coverage as UNKNOWN and make no claim in either ' +
|
|
1845
|
+
'direction.'
|
|
1846
|
+
: outcome === 'completed'
|
|
1847
|
+
? `The Reddit pass completed.${counts}${poolPointer}`
|
|
1848
|
+
: outcome === 'completed_empty'
|
|
1849
|
+
? 'No Reddit evidence cleared this run\'s bounded search and source checks. This does NOT ' +
|
|
1850
|
+
'mean no relevant Reddit discussion exists, and it is not a finding about the market.'
|
|
1851
|
+
: outcome === 'completed_partial'
|
|
1852
|
+
? // ⚠️ A PARTIAL RUN MAY HAVE ACCEPTED NOTHING, and the three cases must not read alike.
|
|
1853
|
+
// `completed_partial` means "a cap, deadline or failure stopped the pass", which is
|
|
1854
|
+
// compatible with ZERO accepted sources — so asserting "accepted evidence is real"
|
|
1855
|
+
// unconditionally makes a claim about evidence that may not exist, and appends a
|
|
1856
|
+
// pointer to a pool the next section renders EMPTY. That is the same defect the
|
|
1857
|
+
// pool-pointer comment above describes for `failed`, one outcome over.
|
|
1858
|
+
//
|
|
1859
|
+
// ⚠️ AND AN UNPROVABLE COUNT IS ITS OWN CASE (FUL-728). This used to take the
|
|
1860
|
+
// evidence wording whenever the counter was unreadable, on the argument that a run
|
|
1861
|
+
// cut short having accepted an unknown amount is likelier to have accepted something
|
|
1862
|
+
// than nothing. That argues from likelihood, and this digest does not assert from
|
|
1863
|
+
// likelihood: the whole outcome vocabulary exists so a reader is never handed a
|
|
1864
|
+
// probable fact wearing a stated one's clothes. `completed_partial` PERMITS zero —
|
|
1865
|
+
// that is the difference from `completed` above, whose own runtime contract
|
|
1866
|
+
// guarantees at least one accepted source and so keeps its wording when the counter
|
|
1867
|
+
// is unreadable. Here nothing guarantees it.
|
|
1868
|
+
//
|
|
1869
|
+
// ⚠️ BOTH COUNTERS MUST BE VALID BEFORE EITHER CLAIM IS AVAILABLE (FUL-740). Earlier
|
|
1870
|
+
// versions let one readable counter settle the question, which repeatedly made a
|
|
1871
|
+
// malformed partner acquire meaning through `> 0` or `<= 0`. Classification now
|
|
1872
|
+
// admits only finite non-negative integers, and the pair is interpreted only after
|
|
1873
|
+
// both pass. Missing, wrong-typed, negative, fractional, NaN and Infinity therefore
|
|
1874
|
+
// share the UNKNOWN arm and can manufacture neither a figure nor a claim.
|
|
1875
|
+
//
|
|
1876
|
+
// ⚠️ AND A CONTRADICTORY PAIR IS THE FOURTH SPELLING, NOT A FOURTH RULE (FUL-732). It
|
|
1877
|
+
// lands in the SAME row of the table as the unreadable case — UNKNOWN, no counts, no
|
|
1878
|
+
// pool pointer — but it gets its own sentence, because "not recorded readably" is
|
|
1879
|
+
// false of two numbers that are both perfectly readable and simply cannot both be
|
|
1880
|
+
// true. Naming the real defect is what tells an operator to go look at the producer
|
|
1881
|
+
// instead of at a dropped telemetry field.
|
|
1882
|
+
countersContradict
|
|
1883
|
+
? `The Reddit pass was cut short${because}, so its coverage is PARTIAL. Its two ` +
|
|
1884
|
+
'acceptance counters CONTRADICT each other, which no real pass produces, so this ' +
|
|
1885
|
+
'note is corrupt on the question of what it accepted — treat Reddit as PARTIALLY ' +
|
|
1886
|
+
'SEARCHED, and make no claim about what it found.'
|
|
1887
|
+
: acceptedNoneProven
|
|
1888
|
+
? `The Reddit pass was cut short${because}, so its coverage is PARTIAL.${counts} ` +
|
|
1889
|
+
'It accepted no evidence before stopping — treat Reddit as PARTIALLY SEARCHED, ' +
|
|
1890
|
+
'not as searched and empty.'
|
|
1891
|
+
: !acceptedProven
|
|
1892
|
+
? `The Reddit pass was cut short${because}, so its coverage is PARTIAL. How much ` +
|
|
1893
|
+
'evidence it accepted before stopping was not recorded readably, and a partial ' +
|
|
1894
|
+
'pass may have accepted none — treat Reddit as PARTIALLY SEARCHED, and make no ' +
|
|
1895
|
+
'claim about what it found.'
|
|
1896
|
+
: `The Reddit pass was cut short${because}, so its coverage is PARTIAL.${counts} ` +
|
|
1897
|
+
'Accepted evidence is real; the absence of more is not evidence of absence.' +
|
|
1898
|
+
poolPointer
|
|
1899
|
+
: outcome === 'failed'
|
|
1900
|
+
? `The Reddit pass did NOT complete${because}. This run makes no claim about Reddit ` +
|
|
1901
|
+
'coverage — treat it as unsearched, NOT as searched and empty.'
|
|
1902
|
+
: // ⚠️ A LIVE BRANCH, NOT A FORMALITY — see `RedditRetrievalNoteSchema`'s `outcome`,
|
|
1903
|
+
// which is deliberately lenient so a client older than the app that answered it
|
|
1904
|
+
// reports UNKNOWN rather than falling silent. Failing closed here is the whole
|
|
1905
|
+
// reason the schema does not reject the token itself.
|
|
1906
|
+
`The Reddit pass reported an outcome this client does not recognise ` +
|
|
1907
|
+
`(\`${safeInline(outcome, 40) ?? 'unreadable'}\`)${because}. Treat Reddit coverage ` +
|
|
1908
|
+
'as UNKNOWN, and upgrade `@clien-ai/mcp` — the app is newer than this client.';
|
|
1909
|
+
return `### Reddit community evidence\n${body}`;
|
|
1910
|
+
}
|
|
1486
1911
|
/**
|
|
1487
1912
|
* Build the trust digest appended to `get_report`'s text.
|
|
1488
1913
|
*
|
|
@@ -1500,6 +1925,9 @@ export function buildTrustDigest(reportData, channel) {
|
|
|
1500
1925
|
renderReportSpine(data, channel),
|
|
1501
1926
|
renderMarketSizing(data),
|
|
1502
1927
|
renderCompetitorSourceQuality(data),
|
|
1928
|
+
// FUL-685: immediately above the pool the accepted rows land in, so a reader meets the
|
|
1929
|
+
// coverage caveat before the evidence rather than after it.
|
|
1930
|
+
renderRedditRetrieval(data),
|
|
1503
1931
|
renderReceiptPools(data, channel),
|
|
1504
1932
|
renderPersonas(data, channel),
|
|
1505
1933
|
renderSycophancy(data, channel),
|