Skip to content
Closed
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
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,55 @@
# Changelog

## Unreleased

## v0.12.0 (2026-08-11)

SDK gaps closed for hosts that need full-file re-ingest, tomb-free similarity
indexes, and mixed doc+chat retrieval under tight budgets.

### Added

- **`replaceSource(sourceFile, docs, opts?)` / `replaceMarkdown`.** Soft-retires
every active node for the source with `retiredBy: 'replace'`, drops their
TF-IDF (and embedding) postings, then appends through the additive path while
exempting the just-retired generation from dedup. Ordinary later `append` of
the old body cannot restore replace tombs. Optional `generation` stamp for
mixed-vintage diagnostics.

- **`forgetSource(sourceFile)`.** Soft-retire a whole source (`retiredBy:
'delete'`) and drop its similarity postings — the SDK primitive that lets
hosts drop content-erasing `__gn-forgotten:` tomb workarounds.

- **`sourcesNeedingChunkerReingest()` / `CURRENT_CHUNKER_ID`.** Lists live
sources whose content nodes lack the `boundaries-v1` stamp so App hosts can
offer opt-in `replaceSource` for pre-0.11.1 shattered chunk boundaries.
Stored cortex is never rewritten in place.

- **Mixed-corpus document floor** (`MIXED_CORPUS_DOC_FLOOR_SHARE = 0.25`) in
final traversal selection, and a **structural doc reserve split**
(`STRUCTURAL_DOC_RESERVE_SHARE = 0.5`) when turn-pairs and document neighbors
both compete for the post-cut expansion budget.

### Changed

- **TF-IDF excludes administrative tombs.** `buildIndexFromGraph` /
`rebuildIndex` skip retired nodes; `deleteNode`, supersede, bulk forget,
cascade, `forgetSource`, and `replaceSource` call `removeDocument` so tombs
neither seed nor dilute IDF.

- **`RetirementReason` includes `'replace'`.** Audit health reports a third
bucket. `blocksReingest` treats replace like supersede for ordinary append.

### Tests

- `tests/unit/replace-source.test.ts`
- `tests/unit/tfidf-tomb-index.test.ts`
- `tests/unit/mixed-corpus-starvation.test.ts`
- `tests/unit/chunker-migration.test.ts`

## v0.11.1 (2026-08-07)


Chunker correctness: sentence boundaries stop tearing identifiers, every chunk
is hard-capped, short pieces merge instead of vanishing, and the suite that
would have caught all three is wired into `npm test`.
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ These belong in `@nehloo/graphnosis`:
- **Ingestion parsers.** Markdown, HTML, JSON, CSV, PDF, plain text. New parsers for common document formats are welcome via issue discussion.
- **Indexing.** TF-IDF index with pluggable analyzers, in-memory embedding index with pluggable adapters.
- **Querying.** `query()` (TF-IDF), `queryHybrid()` (TF-IDF + embeddings), `prompt()` builders, subgraph context serialization.
- **Corrections.** `edit`, `deleteNode`, `supersede`, `correct`, `importMarkdown`, `forgetByTopic`, `forgetByTimeWindow`, `previewForgetTopic`, `retired`. Soft-delete semantics. `edit` supersedes rather than overwriting — the prior version stays readable.
- **Corrections.** `edit`, `deleteNode`, `supersede`, `correct`, `importMarkdown`, `forgetByTopic`, `forgetByTimeWindow`, `previewForgetTopic`, `forgetSource`, `replaceSource`, `retired`. Soft-delete semantics. `edit` supersedes rather than overwriting — the prior version stays readable. `replaceSource` is the full-file re-ingest primitive.
- **Confidence.** `setConfidence` / `setConfidences` — change how much a memory counts without minting or retiring anything.
- **Persistence.** The `.gai` binary format (MessagePack body, big-endian header, checksum, optional HMAC signing) as specified in [`SPEC.md`](SPEC.md), SQLite store, buffer-based I/O for serverless.
- **Determinism.** `asOf` — one caller-supplied instant for a whole query, so the same question against an unchanged graph returns the same answer indefinitely.
- **Failure classification.** Stable error codes and classes (`GraphnosisError`, `isCorruption`, `isVersionSkew`, `isCallerError`) so a consumer can branch on the class instead of matching message text.
- **Analyzer migration.** `migrateAnalyzer` — move an existing index to a different analyzer deliberately, with a count of the terms recovered.
- **Analyzer migration.** `migrateAnalyzer` — move an existing index to a different analyzer deliberately, with a count of the terms recovered. `sourcesNeedingChunkerReingest` / `CURRENT_CHUNKER_ID` — list sources that still need an opt-in `replaceSource` after the 0.11.1 chunker fix.
- **Reflection.** `reflect()` — contradictions, decayed nodes, surprising connections.
- **Adapter interfaces.** `EmbeddingAdapter`, `TextAnalyzer`. New built-in adapters for major providers (OpenAI, Voyage, Cohere) live in `@nehloo/graphnosis/adapters/*`.
- **Federation primitive.** `queryGraphs([...])` for in-process cross-graph queries.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nehloo/graphnosis",
"version": "0.11.1",
"version": "0.12.0",
"description": "AI-native dual-graph knowledge representation — build, query, and persist typed knowledge graphs in-process.",
"author": "Nehloo Interactive LLC",
"license": "Apache-2.0",
Expand Down Expand Up @@ -58,7 +58,7 @@
"longmemeval:audit:multisession": "tsx tests/longmemeval/official/audit-multisession.ts",
"mcp": "tsx src/mcp/server.ts",
"mcp:http": "MCP_TRANSPORT=http tsx src/mcp/server.ts",
"test": "tsx tests/unit/synonym-expander.test.ts && tsx tests/unit/traversal-determinism.test.ts && tsx tests/unit/query-asof-determinism.test.ts && tsx tests/unit/traversal-path-maximum.test.ts && tsx tests/unit/correction-affected-ids.test.ts && tsx tests/unit/error-codes.test.ts && tsx tests/unit/confidence-primitive.test.ts && tsx tests/unit/embedding-cosine-bounded.test.ts && tsx tests/unit/ingest-determinism.test.ts && tsx tests/unit/loader-index-parity.test.ts && tsx tests/unit/seed-budget.test.ts && tsx tests/unit/morphology-key.test.ts && tsx tests/unit/derived-index.test.ts && tsx tests/unit/graph-integrity.test.ts && tsx tests/unit/chunk-boundaries.test.ts && tsx tests/unit/source-update-integrity.test.ts && tsx tests/unit/reflect-no-decay.test.ts && tsx tests/unit/ingest-path-parity.test.ts && tsx tests/unit/structural-expansion.test.ts && tsx tests/unit/structural-expansion-wiring.test.ts && tsx tests/unit/federated-subgraph-scores.test.ts && tsx tests/unit/retirement.test.ts && tsx tests/unit/retirement-expiry.test.ts && tsx tests/unit/legacy-retirement-v080.test.ts && tsx tests/unit/legacy-tomb-write-path.test.ts && tsx tests/unit/supersedes-edge-liveness.test.ts && tsx tests/ablation-scoring/maxwins-vs-additive.ts && node spec/conformance.mjs",
"test": "tsx tests/unit/synonym-expander.test.ts && tsx tests/unit/traversal-determinism.test.ts && tsx tests/unit/query-asof-determinism.test.ts && tsx tests/unit/traversal-path-maximum.test.ts && tsx tests/unit/correction-affected-ids.test.ts && tsx tests/unit/error-codes.test.ts && tsx tests/unit/confidence-primitive.test.ts && tsx tests/unit/embedding-cosine-bounded.test.ts && tsx tests/unit/ingest-determinism.test.ts && tsx tests/unit/loader-index-parity.test.ts && tsx tests/unit/seed-budget.test.ts && tsx tests/unit/morphology-key.test.ts && tsx tests/unit/derived-index.test.ts && tsx tests/unit/graph-integrity.test.ts && tsx tests/unit/chunk-boundaries.test.ts && tsx tests/unit/source-update-integrity.test.ts && tsx tests/unit/reflect-no-decay.test.ts && tsx tests/unit/ingest-path-parity.test.ts && tsx tests/unit/structural-expansion.test.ts && tsx tests/unit/structural-expansion-wiring.test.ts && tsx tests/unit/federated-subgraph-scores.test.ts && tsx tests/unit/retirement.test.ts && tsx tests/unit/retirement-expiry.test.ts && tsx tests/unit/legacy-retirement-v080.test.ts && tsx tests/unit/legacy-tomb-write-path.test.ts && tsx tests/unit/supersedes-edge-liveness.test.ts && tsx tests/unit/replace-source.test.ts && tsx tests/unit/tfidf-tomb-index.test.ts && tsx tests/unit/mixed-corpus-starvation.test.ts && tsx tests/unit/chunker-migration.test.ts && tsx tests/ablation-scoring/maxwins-vs-additive.ts && node spec/conformance.mjs",
"verify:package": "node scripts/verify-package.mjs"
},
"peerDependencies": {
Expand Down
5 changes: 3 additions & 2 deletions src/core/audit/audit-exporter.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type {
KnowledgeGraph,
GraphNode,
DirectedEdge,

Check warning on line 4 in src/core/audit/audit-exporter.ts

View workflow job for this annotation

GitHub Actions / lint · build · test · package

'DirectedEdge' is defined but never used
NodeId,
Contradiction,

Check warning on line 6 in src/core/audit/audit-exporter.ts

View workflow job for this annotation

GitHub Actions / lint · build · test · package

'Contradiction' is defined but never used
ConnectionDiscovery,

Check warning on line 7 in src/core/audit/audit-exporter.ts

View workflow job for this annotation

GitHub Actions / lint · build · test · package

'ConnectionDiscovery' is defined but never used
} from '@/core/types';
import { reflect } from '@/core/optimization/reflection';
import {
Expand Down Expand Up @@ -84,7 +84,7 @@
expiredNodes: number;
/** Retired nodes split by why, from `metadata.retiredBy`. Graphs written
* before that field existed report their nodes under `delete`. */
retiredByReason: { delete: number; supersede: number };
retiredByReason: { delete: number; supersede: number; replace: number };
enrichedNodes: number;
unenrichedNodes: number;
nodesByType: Record<string, number>;
Expand Down Expand Up @@ -293,7 +293,7 @@
let orphanNodes = 0;
let lowConfidenceNodes = 0;
let expiredNodes = 0;
const retiredByReason = { delete: 0, supersede: 0 };
const retiredByReason = { delete: 0, supersede: 0, replace: 0 };
const supersededIds = collectSupersededIds(graph);
let enrichedNodes = 0;
let unenrichedNodes = 0;
Expand Down Expand Up @@ -380,6 +380,7 @@
lines.push(`| Retired Nodes (excluded from prompts, retained here) | ${report.health.expiredNodes} |`);
lines.push(`| — forgotten | ${report.health.retiredByReason.delete} |`);
lines.push(`| — superseded | ${report.health.retiredByReason.supersede} |`);
lines.push(`| — replaced | ${report.health.retiredByReason.replace} |`);
lines.push('');

// Node type breakdown
Expand Down
15 changes: 15 additions & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ export const SEED_COUNT = 5; // Max seed nodes per query
// the chat overlay's prior 123-node tail became a hard 45-node maximum.
export const STRUCTURAL_EXPANSION_BUDGET_SHARE = 0.5;

// Mixed doc+chat corpora: fraction of the final `maxNodes` budget reserved for
// non-conversation content when BOTH classes appear in the scored candidate
// set. Measured on the M4 Alder fixture (one doc gold marker + 40-turn chat
// sharing query vocabulary): without a floor, maxNodes 6/8/10 returned 0
// document content nodes while chat filled every slot. A 25% floor (at least
// one slot) recovers the marker under those budgets without inventing a wider
// BFS. Off when the candidate set is docs-only or chat-only.
export const MIXED_CORPUS_DOC_FLOOR_SHARE = 0.25;

// When structural expansion offers both turn-pairs (priority 0) and document
// neighbors (priority 1), this share of the additions budget is reserved for
// documents so wide chat pairing cannot consume the entire post-cut reserve.
// Same measured failure mode as MIXED_CORPUS_DOC_FLOOR_SHARE, one stage later.
export const STRUCTURAL_DOC_RESERVE_SHARE = 0.5;

// Oversampling factor for per-variant lexical candidate generation.
//
// Each expanded query variant generates `SEED_COUNT * SEED_OVERSAMPLE` candidates
Expand Down
35 changes: 30 additions & 5 deletions src/core/corrections/correction-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
DirectedEdge,
NodeId,
NodeType,
ExtractedChunk,

Check warning on line 8 in src/core/corrections/correction-engine.ts

View workflow job for this annotation

GitHub Actions / lint · build · test · package

'ExtractedChunk' is defined but never used
} from '@/core/types';
import { chunkDocument } from '@/core/extraction/chunker';
import { parseMarkdown } from '@/core/ingestion/parsers/markdown-parser';
import { addDocument, computeIdf } from '@/core/similarity/tfidf';
import { addDocument, computeIdf, removeDocument } from '@/core/similarity/tfidf';
import type { TfidfIndex } from '@/core/types';
import { buildDirectedEdges, chunkKey } from '@/core/graph/directed-edges';

Check warning on line 14 in src/core/corrections/correction-engine.ts

View workflow job for this annotation

GitHub Actions / lint · build · test · package

'chunkKey' is defined but never used

Check warning on line 14 in src/core/corrections/correction-engine.ts

View workflow job for this annotation

GitHub Actions / lint · build · test · package

'buildDirectedEdges' is defined but never used
import { extractEntities } from '@/core/extraction/entity-extractor';
import { collectSupersededIds, isRetired, retireNode } from '@/core/graph/retirement';
import { simpleHash } from '@/core/graph/content-hash';
Expand Down Expand Up @@ -66,7 +66,7 @@
case 'add':
return applyAdd(graph, tfidfIndex, correction);
case 'delete':
return applyDelete(graph, correction);
return applyDelete(graph, tfidfIndex, correction);
case 'supersede':
return applySupersede(graph, tfidfIndex, correction);
default:
Expand Down Expand Up @@ -197,6 +197,7 @@
// Soft-delete: mark a node as expired, don't remove it
function applyDelete(
graph: KnowledgeGraph,
tfidfIndex: TfidfIndex,
correction: Correction
): { success: boolean; error?: string; affectedNodeId?: NodeId } {
if (!correction.nodeId) {
Expand Down Expand Up @@ -233,6 +234,10 @@
if (retired) {
node.metadata.deletedAt = now;
node.metadata.deleteReason = correction.reason;
// Drop similarity postings. The tomb stays in the graph for audit; it must
// not keep seeding or diluting IDF. Re-ingest of the same body restores a
// fresh live id via ordinary append (retired-by-delete does not block).
if (removeDocument(tfidfIndex, node.id)) computeIdf(tfidfIndex);
}

graph.metadata.updatedAt = Date.now();
Expand Down Expand Up @@ -310,6 +315,7 @@
if (retired) {
oldNode.metadata.deletedAt = now;
oldNode.metadata.deleteReason = correction.reason;
if (removeDocument(tfidfIndex, oldNode.id)) computeIdf(tfidfIndex);
}

graph.metadata.directedEdgeCount = graph.directedEdges.size;
Expand Down Expand Up @@ -363,13 +369,14 @@

// Bulk forgetting: soft-delete nodes by time window
export function forgetByTimeWindow(
graph: KnowledgeGraph,
graph: KnowledgeGraph & { tfidfIndex?: TfidfIndex },
before: number, // Timestamp: forget everything created before this
reason: string = 'system:retention-policy'
): { forgotten: number } {
let forgotten = 0;
const now = Date.now();
const supersededIds = collectSupersededIds(graph);
const dropped: NodeId[] = [];

for (const [, node] of graph.nodes) {
if (node.type === 'document' || node.type === 'section') continue;
Expand All @@ -379,18 +386,21 @@
retireNode(node, { retiredBy: 'delete', reason, now, supersededIds });
node.metadata.forgottenAt = now;
node.metadata.forgetReason = reason;
dropped.push(node.id);
forgotten++;
}
}

dropTombsFromIndex(graph.tfidfIndex, dropped);

graph.metadata.updatedAt = now;
graph.metadata.version++;
return { forgotten };
}

// Bulk forgetting: soft-delete nodes by topic (entity match)
export function forgetByTopic(
graph: KnowledgeGraph,
graph: KnowledgeGraph & { tfidfIndex?: TfidfIndex },
topic: string,
reason: string = `system:topic-forget:${topic}`,
opts: { dryRun?: boolean } = {}
Expand Down Expand Up @@ -441,6 +451,8 @@

if (opts.dryRun) return { forgotten: 0, nodeIds };

dropTombsFromIndex(graph.tfidfIndex, nodeIds);

graph.metadata.updatedAt = now;
graph.metadata.version++;
return { forgotten, nodeIds };
Expand All @@ -449,7 +461,7 @@
// Cascade soft-delete: when a source node is soft-deleted,
// follow edges to soft-delete all downstream nodes from that source
export function cascadeSoftDelete(
graph: KnowledgeGraph,
graph: KnowledgeGraph & { tfidfIndex?: TfidfIndex },
nodeId: NodeId,
reason: string = 'system:cascade-delete'
): { cascaded: number } {
Expand All @@ -460,6 +472,7 @@
// Once, not per node: the cascade reaches every node sharing the source file,
// and any of them may be a legacy supersede tomb the delete must not overwrite.
const supersededIds = collectSupersededIds(graph);
const dropped: NodeId[] = [];

while (queue.length > 0) {
const current = queue.shift()!;
Expand All @@ -474,6 +487,7 @@
if (retireNode(node, { retiredBy: 'delete', reason, now, supersededIds })) {
node.metadata.forgottenAt = now;
node.metadata.forgetReason = reason;
dropped.push(node.id);
if (current !== nodeId) cascaded++; // Don't count the root node
}

Expand All @@ -495,11 +509,22 @@
}
}

dropTombsFromIndex(graph.tfidfIndex, dropped);

graph.metadata.updatedAt = now;
graph.metadata.version++;
return { cascaded };
}

function dropTombsFromIndex(index: TfidfIndex | undefined, ids: readonly NodeId[]): void {
if (!index || ids.length === 0) return;
let dropped = false;
for (const id of ids) {
if (removeDocument(index, id)) dropped = true;
}
if (dropped) computeIdf(index);
}

function classifyCorrectionType(text: string): NodeType {
const lower = text.toLowerCase();
if (/\b(is defined as|refers to|means)\b/.test(lower)) return 'definition';
Expand Down
42 changes: 42 additions & 0 deletions src/core/graph/chunker-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { CURRENT_CHUNKER_ID } from '@/core/types';
import type { KnowledgeGraph } from '@/core/types';
import { collectSupersededIds, isRetired } from './retirement';

const STRUCTURAL = new Set(['document', 'section']);

/**
* Live source files whose content nodes were NOT written under the current
* chunker (`CURRENT_CHUNKER_ID` / `boundaries-v1`).
*
* Pre-0.11.1 splitters could shatter identifiers and drop short tails. There is
* no safe in-place rewrite of stored cortex — identity, edges, and citations
* bind to the old chunks. The host should offer opt-in `replaceSource` per
* listed file (or a bulk re-ingest) so new boundaries land as a fresh
* generation under `retiredBy: 'replace'`.
*
* Identity-extraction and other synthetic sources are skipped: they are not
* file re-ingest targets.
*/
export function sourcesNeedingChunkerReingest(
graph: KnowledgeGraph,
at: number = Date.now(),
): string[] {
const supersededIds = collectSupersededIds(graph);
const byFile = new Map<string, { live: number; stamped: number }>();

for (const node of graph.nodes.values()) {
if (STRUCTURAL.has(node.type)) continue;
if (isRetired(node, at, supersededIds)) continue;
const file = node.source.file;
if (!file || file === 'identity-extraction') continue;
const row = byFile.get(file) ?? { live: 0, stamped: 0 };
row.live++;
if (node.metadata.chunkerId === CURRENT_CHUNKER_ID) row.stamped++;
byFile.set(file, row);
}

return [...byFile.entries()]
.filter(([, row]) => row.live > 0 && row.stamped < row.live)
.map(([file]) => file)
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
}
3 changes: 2 additions & 1 deletion src/core/graph/graph-builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { nanoid } from 'nanoid';
import { CURRENT_INGEST_POLICY_ID } from '@/core/types';
import { CURRENT_INGEST_POLICY_ID, CURRENT_CHUNKER_ID } from '@/core/types';
import type {
KnowledgeGraph,
GraphNode,
Expand Down Expand Up @@ -56,6 +56,7 @@ export function buildGraph(
...chunk.metadata,
chunkOrder: chunk.order,
ingestPolicyId: CURRENT_INGEST_POLICY_ID,
chunkerId: CURRENT_CHUNKER_ID,
},
level: 0,
confidence: 0.9, // Default confidence for extracted content
Expand Down
Loading
Loading