Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions FIDELITY-HONESTY-REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Fidelity Honesty Report

Generated: 2026-07-12

## Scope

Implemented SPEC.md fidelity honesty fixes for compile, serve, and present:

- `unknown` is now a first-class article source-fidelity state.
- Articles with zero sources, all-unmatched sources, or pre-fidelity raw captures no longer masquerade as `full`.
- Articles with matched full captures plus unmatched sources can remain `full`, with `unknownSourceCount` exposed in `notes.json`.
- Raw source matching indexes both `source_url` and `final_url`.
- Duplicate raw captures resolve deterministically by best fidelity (`full` > `extract` > `failed` > `unknown`), then latest frontmatter date.
- Duplicate raw URL collisions are logged during compile.
- Serve retrieval warnings remain limited to `degraded`; `unknown` appears in `grimoire_coverage_gaps` as untracked provenance.
- Present gives `unknown` articles no badge; hub stats include untracked provenance counts.

## Verification

- `npm test` — 24 files passed, 373 tests passed.
- `npm run build` — rebuilt `dist/compile.js`, `dist/serve.js`, `dist/present.js`, `dist/research.js`, and source maps.

## New Coverage

Tests cover:

- Zero-source article compiles to `sourceFidelity: "unknown"`.
- All-unmatched article compiles to `sourceFidelity: "unknown"`.
- Pre-fidelity raw capture compiles to `sourceFidelity: "unknown"`.
- `final_url` bridges redirect mismatch between cited URL and raw archive.
- Duplicate captures prefer the best fidelity and log the collision.
- Serve does not append false warnings for `unknown` provenance.
- Serve coverage gaps lists unknown provenance separately from degraded captures.
- Present suppresses badges for `unknown` and reports untracked provenance in hub stats.
4 changes: 2 additions & 2 deletions dist/compile.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/compile.js.map

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/present.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/present.js.map

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/serve.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/serve.js.map

Large diffs are not rendered by default.

87 changes: 80 additions & 7 deletions lib/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ interface ArticleFrontmatter {
}

export type RawSourceFidelity = 'full' | 'extract' | 'failed' | 'unknown';
export type ArticleSourceFidelity = 'full' | 'mixed' | 'degraded';
export type ArticleSourceFidelity = 'full' | 'mixed' | 'degraded' | 'unknown';

interface SourceFidelityAssessment {
readonly sourceFidelity: ArticleSourceFidelity;
Expand All @@ -133,8 +133,10 @@ export interface SourceFidelitySummary {
readonly full: number;
readonly mixed: number;
readonly degraded: number;
readonly unknown: number;
readonly unknownSources: number;
readonly degradedArticles: readonly string[];
readonly unknownArticles: readonly string[];
}

type SchemaTaxonomy = 'emergent' | 'defined' | 'unknown';
Expand Down Expand Up @@ -164,6 +166,7 @@ export interface NoteManifestEntry {
readonly linksTo: readonly string[];
readonly sources: readonly { readonly url: string; readonly title: string }[];
readonly sourceFidelity: ArticleSourceFidelity;
readonly unknownSourceCount: number;
}

export interface CandidateTagGroup {
Expand Down Expand Up @@ -489,23 +492,79 @@ function parseRawSourceFidelity(value: unknown): RawSourceFidelity {
return 'unknown';
}

interface RawSourceFidelityEntry {
readonly fidelity: RawSourceFidelity;
readonly capturedDate: string | null;
readonly filePath: string;
}

function rawFidelityRank(fidelity: RawSourceFidelity): number {
if (fidelity === 'full') return 3;
if (fidelity === 'extract') return 2;
if (fidelity === 'failed') return 1;
return 0;
}

function rawCaptureDate(data: Record<string, unknown>): string | null {
return normalizeFrontmatterDate(data.captured_at)
?? normalizeFrontmatterDate(data.collected)
?? normalizeFrontmatterDate(data.published)
?? normalizeFrontmatterDate(data.updated);
}

function preferRawFidelityEntry(
existing: RawSourceFidelityEntry,
candidate: RawSourceFidelityEntry,
): RawSourceFidelityEntry {
const existingRank = rawFidelityRank(existing.fidelity);
const candidateRank = rawFidelityRank(candidate.fidelity);
if (candidateRank !== existingRank) {
return candidateRank > existingRank ? candidate : existing;
}
const existingDate = existing.capturedDate ?? '';
const candidateDate = candidate.capturedDate ?? '';
if (candidateDate !== existingDate) {
return candidateDate > existingDate ? candidate : existing;
}
return existing;
}

export function buildRawSourceFidelityIndex(workspaceDir: string): ReadonlyMap<string, RawSourceFidelity> {
const byUrl = new Map<string, RawSourceFidelity>();
const byUrl = new Map<string, RawSourceFidelityEntry>();

for (const filePath of collectRawFiles(join(workspaceDir, 'raw'))) {
try {
const raw = readFileSync(filePath, 'utf-8');
const data = matter(raw).data ?? {};
const sourceUrl = typeof data.source_url === 'string' ? data.source_url : null;
if (!sourceUrl) continue;

byUrl.set(normalizeUrl(sourceUrl), parseRawSourceFidelity(data.fidelity));
const fidelity = parseRawSourceFidelity(data.fidelity);
const capturedDate = rawCaptureDate(data);
const urls = new Set<string>();

if (typeof data.source_url === 'string') urls.add(normalizeUrl(data.source_url));
if (typeof data.final_url === 'string') urls.add(normalizeUrl(data.final_url));

for (const url of urls) {
if (!url) continue;
const candidate = { fidelity, capturedDate, filePath };
const existing = byUrl.get(url);
if (!existing) {
byUrl.set(url, candidate);
continue;
}
if (existing.filePath !== filePath) {
const preferred = preferRawFidelityEntry(existing, candidate);
console.log(
` ⚠ raw source fidelity collision for ${url}: keeping ${preferred.fidelity} from ${relative(workspaceDir, preferred.filePath)}`,
);
byUrl.set(url, preferred);
}
}
} catch {
continue;
}
}

return byUrl;
return new Map([...byUrl].map(([url, entry]) => [url, entry.fidelity]));
}

export function aggregateSourceFidelity(
Expand All @@ -514,13 +573,15 @@ export function aggregateSourceFidelity(
): SourceFidelityAssessment {
let worst: ArticleSourceFidelity = 'full';
let unknownSourceCount = 0;
let matchedSourceCount = 0;

for (const source of sources) {
const fidelity = rawFidelityByUrl.get(normalizeUrl(source.url)) ?? 'unknown';
if (fidelity === 'unknown') {
unknownSourceCount += 1;
continue;
}
matchedSourceCount += 1;
if (fidelity === 'failed') {
worst = 'degraded';
continue;
Expand All @@ -530,6 +591,10 @@ export function aggregateSourceFidelity(
}
}

if (sources.length === 0 || matchedSourceCount === 0) {
return { sourceFidelity: 'unknown', unknownSourceCount };
}

return { sourceFidelity: worst, unknownSourceCount };
}

Expand All @@ -542,13 +607,19 @@ export function summarizeSourceFidelity(
.filter(note => note.sourceFidelity === 'degraded')
.map(note => note.slug)
.sort();
const unknownArticles = content
.filter(note => note.sourceFidelity === 'unknown')
.map(note => note.slug)
.sort();

return {
full: content.filter(note => note.sourceFidelity === 'full').length,
mixed: content.filter(note => note.sourceFidelity === 'mixed').length,
degraded: degradedArticles.length,
unknown: unknownArticles.length,
unknownSources,
degradedArticles,
unknownArticles,
};
}

Expand Down Expand Up @@ -717,6 +788,7 @@ async function compile(): Promise<void> {
checked: fm.checked,
evergreen: fm.evergreen,
sourceFidelity: fidelity.sourceFidelity,
unknownSourceCount: fidelity.unknownSourceCount,
};
});
const sourceFidelitySummary = summarizeSourceFidelity(noteManifest, unknownFidelitySources);
Expand Down Expand Up @@ -789,6 +861,7 @@ async function compile(): Promise<void> {
console.log(` Components: ${components.length}`);
console.log(` Graph density: ${stats.density.toFixed(3)}`);
console.log(` Source fidelity: ${sourceFidelitySummary.degraded} articles on degraded sources`);
console.log(` Source fidelity: ${sourceFidelitySummary.unknown} articles with untracked provenance`);
if (sourceFidelitySummary.unknownSources > 0) {
console.log(
` Source fidelity: fidelity untracked (pre-v0.5 wiki) — ${sourceFidelitySummary.unknownSources} cited source${sourceFidelitySummary.unknownSources === 1 ? '' : 's'}`,
Expand Down
6 changes: 3 additions & 3 deletions lib/present/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export const ArticleSchema = z.object({
headings: z.array(z.object({ level: z.number(), text: z.string() })),
confidence: z.string(),
sources: z.array(z.object({ url: z.string(), title: z.string() })),
sourceFidelity: z.enum(['full', 'mixed', 'degraded']).default('full'),
sourceFidelity: z.enum(['full', 'mixed', 'degraded', 'unknown']).default('unknown'),
});

export function validateArticleData(article: unknown, slug: string): ArticleData | null {
Expand Down Expand Up @@ -352,7 +352,7 @@ export async function loadSiteData(workspacePath: string): Promise<SiteData> {
headings: Array<{ level: number; text: string }>;
confidence: string;
sources: Array<{ url: string; title: string }>;
sourceFidelity?: 'full' | 'mixed' | 'degraded';
sourceFidelity?: 'full' | 'mixed' | 'degraded' | 'unknown';
}>;

const graphRaw = readJSON(join(compileDir, 'graph.json')) as {
Expand Down Expand Up @@ -439,7 +439,7 @@ export async function loadSiteData(workspacePath: string): Promise<SiteData> {
: manifest.headings,
confidence: manifest.confidence ?? '',
sources: manifest.sources ?? [],
sourceFidelity: manifest.sourceFidelity ?? 'full',
sourceFidelity: manifest.sourceFidelity ?? 'unknown',
...(category ? { category } : {}),
}, slug);

Expand Down
6 changes: 5 additions & 1 deletion lib/present/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface HubStats {
readonly articleCount: number;
readonly sourceCount: number;
readonly sourceWarnings: number;
readonly sourceUntracked: number;
readonly tagCount: number;
readonly crossRefs: number;
readonly density: number | null;
Expand Down Expand Up @@ -38,7 +39,10 @@ export function computeHubStats(data: SiteData): HubStats {
return {
articleCount: data.articles.length,
sourceCount: sourceUrls.size,
sourceWarnings: data.articles.filter(article => article.sourceFidelity !== 'full').length,
sourceWarnings: data.articles.filter(article =>
article.sourceFidelity === 'mixed' || article.sourceFidelity === 'degraded',
).length,
sourceUntracked: data.articles.filter(article => article.sourceFidelity === 'unknown').length,
tagCount: tags.size,
crossRefs,
density: data.articles.length < 10 ? null : Math.min(100, Math.round(densityRatio * 100)),
Expand Down
5 changes: 5 additions & 0 deletions lib/present/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ function generateHub(data: SiteData, config: DesignConfig): string {
value: String(stats.sourceWarnings),
title: 'Articles compiled from partial or failed raw captures.',
},
{
label: 'untracked provenance',
value: String(stats.sourceUntracked),
title: 'Articles whose source fidelity is unknown.',
},
{ label: 'tags', value: String(stats.tagCount) },
{ label: 'cross-refs', value: String(stats.crossRefs) },
{
Expand Down
2 changes: 1 addition & 1 deletion lib/present/modes/read-article.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function freshnessBadge(data: SiteData, slug: string): string {
}

function fidelityBadge(article: ArticleData): string {
if (article.sourceFidelity === 'full') return '';
if (article.sourceFidelity === 'full' || article.sourceFidelity === 'unknown') return '';
const title = article.sourceFidelity === 'degraded'
? 'At least one cited source could not be captured; verify claims against the source list.'
: 'At least one cited source was only partially captured; verify claims against the source list.';
Expand Down
2 changes: 1 addition & 1 deletion lib/present/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type Density = 'compact' | 'comfortable' | 'spacious';

export const ALL_MODES = ['read', 'graph', 'search', 'feed', 'gaps', 'quiz'] as const;
export type ModeId = (typeof ALL_MODES)[number];
export type ArticleSourceFidelity = 'full' | 'mixed' | 'degraded';
export type ArticleSourceFidelity = 'full' | 'mixed' | 'degraded' | 'unknown';

export interface DesignConfig {
readonly palette: string;
Expand Down
22 changes: 18 additions & 4 deletions lib/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { FreshnessReport } from './freshness.js';

// --- Types ---

type ArticleSourceFidelity = 'full' | 'mixed' | 'degraded';
type ArticleSourceFidelity = 'full' | 'mixed' | 'degraded' | 'unknown';

interface NoteManifestEntry {
readonly slug: string;
Expand All @@ -25,6 +25,7 @@ interface NoteManifestEntry {
readonly confidence: string;
readonly sources: readonly { url: string; title: string }[];
readonly sourceFidelity?: ArticleSourceFidelity;
readonly unknownSourceCount?: number;
}

interface GraphData {
Expand Down Expand Up @@ -56,7 +57,7 @@ const DEGRADED_FIDELITY_WARNING =
'Fidelity warning: this article was compiled from degraded raw source capture; verify against sources.';

function sourceFidelityOf(note: NoteManifestEntry | undefined): ArticleSourceFidelity {
return note?.sourceFidelity ?? 'full';
return note?.sourceFidelity ?? 'unknown';
}

function fidelityWarningFor(note: NoteManifestEntry | undefined): string {
Expand Down Expand Up @@ -1302,7 +1303,19 @@ export function handleCoverageGaps(data: WikiData): string {
gaps.push('', '### Degraded Source Fidelity', '', ...degradedEntries);
}

// 5. Articles past their staleness window (freshness.json, v0.4.0+).
// 5. Articles whose provenance cannot be tracked. This is distinct from
// degraded capture: no warning is added to retrieval responses, but wiki
// health should still show that provenance is not known.
const unknownEntries = data.notes
.filter(note => sourceFidelityOf(note) === 'unknown')
.sort((a, b) => a.slug.localeCompare(b.slug))
.map(note => `- [UNTRACKED PROVENANCE] "${note.title}" (${note.slug}): cited source fidelity is unknown`);

if (unknownEntries.length > 0) {
gaps.push('', '### Untracked Provenance', '', ...unknownEntries);
}

// 6. Articles past their staleness window (freshness.json, v0.4.0+).
// Workspaces compiled before v0.4.0 have no freshness report — degrade to
// the legacy output silently.
const staleEntries: string[] = [];
Expand Down Expand Up @@ -1342,6 +1355,7 @@ export function handleCoverageGaps(data: WikiData): string {
thinArticles.length +
orphanEntries.length +
degradedEntries.length +
unknownEntries.length +
staleEntries.length;
return [
`## Coverage Gaps (${totalGaps} issues)`,
Expand Down Expand Up @@ -1443,7 +1457,7 @@ function createServer(data: WikiData): McpServer {

server.tool(
'grimoire_coverage_gaps',
'Identify structural weaknesses: tags with only one article, articles below median word count, topics referenced but not yet written, degraded raw-source fidelity, plus articles past their staleness window. Use when asking about wiki health, what to write next, or what needs re-verification.',
'Identify structural weaknesses: tags with only one article, articles below median word count, topics referenced but not yet written, degraded raw-source fidelity, untracked provenance, plus articles past their staleness window. Use when asking about wiki health, what to write next, or what needs re-verification.',
{},
async () => ({
content: [{ type: 'text' as const, text: handleCoverageGaps(data) }],
Expand Down
Loading