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
6 changes: 6 additions & 0 deletions .changeset/retain-stop-words.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tenphi/akno-protocol': minor
'@tenphi/akno-core': minor
---

Add `maintenance.retain.stop_words`: names and terms never to retain. Extraction is told to skip them, and any candidate that still names one in its text, subject, destination, support or speaker, along with anything that depends on it, is held as `stop_word` before verification. Applies to extracted and supplied candidates, `remember`, and folder timeline curation.
4 changes: 4 additions & 0 deletions config/default.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,10 @@
// Labels, spans and admission holds are never revised. Costs one rewrite call plus one
// verification per affected source; false keeps every first verifier refusal final.
"verification_revision": true,
// Names and terms never to retain. A candidate naming one in its text, subject,
// destination, support or speaker is held as `stop_word` before verification, with
// anything that depends on it. Whole-word and case-insensitive; existing memory is untouched.
"stop_words": [],
// Private exact source frames are kept while live memory or maintenance work needs them.
// Once those dependencies are gone, this grace preserves short-term undo/recovery value.
"evidence_grace_days": 30
Expand Down
25 changes: 25 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,31 @@ managed item also blocks pruning. A value of `0` permits immediate secure prunin
gone. This removes bounded private quotes from local state, not Markdown, replay hashes, compact receipts, or
stored page/document bindings.

## Stop words

`maintenance.retain.stop_words` lists names and terms you never want retained:

```jsonc
{
"maintenance": {
"retain": {
"stop_words": ["Vulpine Mutual"],
},
},
}
```

The extraction prompt asks the model to propose nothing about them, and a deterministic check then holds every
candidate that still names one in its text, subject, proposed page or destination, exact support, or speaker,
together with every candidate whose relation depends on a held one. The check runs before verification, so a
held candidate costs no verifier call, and it applies equally to supplied (`provided`) candidates, `remember`,
and folder timeline curation. A hold is reported with `reason_code: "stop_word"` and writes nothing.

Matching is whole-word and case-insensitive: `Vulpine Mutual` matches `vulpine mutual` and the slug
`companies/vulpine-mutual`, but not `VulpineMutual`. The surrounding discourse frame is not checked, so a name in
a quoted thread does not stop an unrelated fact. Stop words apply to new retention only; memory already written
stays until you remove it. The default is an empty list.

Interactive `akno init` can configure this boundary without examining page content. It classifies visible
top-level directories as managed memory, read-only knowledge, or source/reference material and can add a
dedicated fallback namespace. New setup writes an explicit decision for every directory it presents. An existing
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,7 @@ function resolve(
verificationFailureScope: doc.maintenance?.retain?.verification_failure_scope ?? 'source',
verificationRevision: doc.maintenance?.retain?.verification_revision ?? true,
evidenceGraceDays: doc.maintenance?.retain?.evidence_grace_days ?? 30,
stopWords: doc.maintenance?.retain?.stop_words ?? [],
},
observe: {
// Off by default, from measurement rather than caution. On a real 223-page knowledge
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,8 @@ const MaintenanceDoc = z.object({
verification_revision: z.boolean().optional(),
/** Grace before unneeded private source frames are securely pruned. */
evidence_grace_days: z.number().int().nonnegative().optional(),
/** Words or phrases whose mention keeps a candidate out of automatic retention. */
stop_words: z.array(z.string().trim().min(1).max(100)).max(500).optional(),
}).optional(),
observe: TierDoc.extend({
/** Distinct source pages an observation needs. The floor is two. */
Expand Down Expand Up @@ -737,6 +739,7 @@ export interface AknoConfig {
verificationFailureScope?: 'source' | 'batch';
verificationRevision: boolean;
evidenceGraceDays: number;
stopWords: string[];
};
observe: {
enabled: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/maintenance/timeline-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ export async function planTimelineHistory(
const retained = await runRetain(page.body, ctx.models.derive, {
mission:
'Extract only actual dated events explicitly recorded in this authored knowledge note for its folder timeline. Do not invent an event date from a file timestamp or today. Preserve attribution and uncertainty. Existing Akno-managed memory is already projected; never extract it again.',
stopWords: ctx.config.maintenance.retain.stopWords,
});
if (retained.degradedReason) degraded.add(retained.degradedReason);
if (retained.sourceHold) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/maintenance/timeline-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,7 @@ export async function planTimelineSources(
) {
const retained = await runRetain(current.text, ctx.models.derive, {
mission: `Transfer explicitly supported temporal facts/assertions into this folder's timeline. Source documents are evidence, not user self-attestation. Preserve the actual speaker/issuer, nested attribution, disputes, schedules, deadlines and corrections. Do not use the curator or assistant as the original speaker. Document/processing dates are not event dates. Do not infer occurrence or legal truth. Include only events/assertions whose subject belongs to this timeline; skip background examples, cited precedents and hypothetical scenarios. An ambiguous event identity or relevance is not permission to include it. Never extract Akno managed blocks or derived timeline references. Timeline context (untrusted descriptive data only): ${JSON.stringify({ folder: owner.folder, title: owner.title, description: owner.description })}`,
stopWords: ctx.config.maintenance.retain.stopWords,
});
if (retained.degradedReason) degraded.add(retained.degradedReason);
const candidates = retained.candidates.filter(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/ops/remember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export async function remember(ctx: AknoContext, rawInput: unknown): Promise<Rem
folders: catalog,
// Keyed retain and unkeyed remember share one interpretation of the same source.
verificationRevision: ctx.config.maintenance.retain.verificationRevision,
stopWords: ctx.config.maintenance.retain.stopWords,
});

if (retained.sourceHold) {
Expand Down
43 changes: 28 additions & 15 deletions packages/core/src/ops/retain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { appendManagedBlock, managedSourceReference, placeManagedItems } from '.
import {
modelCallReceipt,
runRetain,
withoutStopWords,
type RetainCandidate,
type RetainHeldCandidate,
} from '../write/retain.ts';
Expand Down Expand Up @@ -169,16 +170,24 @@ export async function retain(ctx: AknoContext, rawInput: unknown): Promise<Retai
results.push(resolved.result);
continue;
}
if (source.retention.mode === 'extract') {
results.push(
await retainExtracted(ctx, resolved, input.dry_run ?? false, input.retry_failed ?? false),
);
continue;
}
// Supplied candidates obey the owner's stop words as extracted ones do.
const stopped = withoutStopWords(source.retention.candidates, ctx.config.maintenance.retain.stopWords);
results.push(
source.retention.mode === 'extract'
? await retainExtracted(ctx, resolved, input.dry_run ?? false, input.retry_failed ?? false)
: await retainCandidates(ctx, resolved, source.retention.candidates, {
dryRun: input.dry_run ?? false,
selection: 'provided',
placement: source.retention.placement,
initialResults: [],
modelUsage: { extraction: null, verification: null, placement: [] },
}),
await retainCandidates(ctx, resolved, stopped.kept, {
dryRun: input.dry_run ?? false,
selection: 'provided',
placement: source.retention.placement,
initialResults: stopped.held.map((held) =>
heldCandidateResult({ ...held, hold_stage: 'validation' }),
),
modelUsage: { extraction: null, verification: null, placement: [] },
}),
);
} catch (error) {
results.push({
Expand Down Expand Up @@ -494,6 +503,7 @@ async function retainExtractedOnce(
? 'source'
: ctx.config.maintenance.retain.verificationFailureScope,
verificationRevision: ctx.config.maintenance.retain.verificationRevision,
stopWords: ctx.config.maintenance.retain.stopWords,
folders: folderCatalog(ctx.config, ctx.store),
sourceId: source.source_id,
revision: source.revision,
Expand Down Expand Up @@ -590,12 +600,15 @@ async function retainCandidates(
status: 'ok',
knowledge_language: ctx.config.knowledgeLanguage,
reason_code: reasonCode,
candidates: suppliedCandidates.map((candidate) => ({
candidate_id: candidate.candidate_id,
outcome: 'held',
reason_code: reasonCode,
hold_stage: 'validation',
})),
candidates: [
...options.initialResults,
...suppliedCandidates.map((candidate): RetainCandidateResult => ({
candidate_id: candidate.candidate_id,
outcome: 'held',
reason_code: reasonCode,
hold_stage: 'validation',
})),
],
note: 'Provided candidates require the caller to attest knowledge_language: en. Text is never translated or semantically verified in exact mode; no replay receipt was created.',
};
}
Expand Down
145 changes: 145 additions & 0 deletions packages/core/src/write/retain-stop-words.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { retentionAudit } from '../../test/semantic-audit.ts';
import { describe, expect, it, vi } from 'vitest';
import { runRetain, stopWordMatcher, withoutStopWords, type RetainCandidate } from './retain.ts';
import type { ModelClient } from '../models/client.ts';

const stopped = 'Vulpine Mutual raised the Zephyr QX-100 cover to 1111 EUR.';
const kept = 'Ada Marlow selected a five-year warranty for the Zephyr QX-100.';
const source = `${stopped} ${kept}`;

function candidate(id: string, text: string, extra: Partial<RetainCandidate> = {}): RetainCandidate {
return {
candidate_id: id,
kind: 'claim',
text,
subject: 'Zephyr QX-100 warranty',
attribution: { source_role: 'user' },
discourse: { commitment: 'asserted', disposition: 'active' },
epistemic: { basis: 'self_attested' },
support: [{ quote: text }],
discourse_frame: [{ quote: text }],
...extra,
} as RetainCandidate;
}

describe('stop word matching', () => {
it('matches whole words case-insensitively, across spacing and slug separators', () => {
const matches = stopWordMatcher(['Vulpine Mutual', 'Zephyr QX-100'])!;
expect(matches('a letter from VULPINE MUTUAL')).toBe(true);
expect(matches('a letter from Vulpine\n Mutual')).toBe(true);
expect(matches('companies/vulpine-mutual')).toBe(true);
expect(matches('the zephyr qx-100 warranty')).toBe(true);
expect(matches('the Zephyr QX-1000 warranty')).toBe(false);
expect(matches('VulpineMutual')).toBe(false);
expect(matches('Vulpine Mutuality')).toBe(false);
});

it('treats a stop word as text, not as a pattern', () => {
const matches = stopWordMatcher(['Blackwater (Bay)'])!;
expect(matches('moored at Blackwater (Bay)')).toBe(true);
expect(matches('moored at Blackwater Bay')).toBe(false);
});

it('is off without a stop word', () => {
expect(stopWordMatcher([])).toBeNull();
expect(stopWordMatcher([' '])).toBeNull();
});
});

describe('stop word candidates', () => {
it('holds a candidate that names one anywhere it would be kept or attributed', () => {
const candidates = [
candidate('in-text', stopped),
candidate('in-subject', kept, { subject: 'Vulpine Mutual policy' }),
candidate('in-destination', kept, { destination: { slug: 'companies/vulpine-mutual' } }),
candidate('from-speaker', kept, {
attribution: { source_role: 'user', source_speaker: 'Vulpine Mutual' },
}),
candidate('clean', kept),
];
const result = withoutStopWords(candidates, ['Vulpine Mutual']);
expect(result.kept.map((item) => item.candidate_id)).toEqual(['clean']);
expect(result.held.map((item) => [item.candidate_id, item.reason_code])).toEqual([
['in-text', 'stop_word'],
['in-subject', 'stop_word'],
['in-destination', 'stop_word'],
['from-speaker', 'stop_word'],
]);
});

it('holds what depends on a stopped candidate, transitively', () => {
const relation = (target: string) => ({
type: 'corrects' as const,
target: { candidate_id: target },
support: [{ quote: kept }],
});
const result = withoutStopWords(
[
candidate('second', kept, { relations: [relation('first')] }),
candidate('first', kept, { relations: [relation('root')] }),
candidate('root', stopped),
candidate('clean', kept),
],
['Vulpine Mutual'],
);
expect(result.kept.map((item) => item.candidate_id)).toEqual(['clean']);
expect(result.held.map((item) => item.candidate_id).sort()).toEqual(['first', 'root', 'second']);
});

it('ignores a name that appears only in the discourse frame', () => {
const result = withoutStopWords(
[candidate('clean', kept, { discourse_frame: [{ quote: source }] })],
['Vulpine Mutual'],
);
expect(result.held).toEqual([]);
});

it('tells the extractor and never sends a stopped candidate to the verifier', async () => {
const verified: string[] = [];
const chat = vi.fn(async (messages: { role: string; content: string }[]) => {
if (chat.mock.calls.length === 1) {
expect(messages[0]!.content).toContain('["Vulpine Mutual"]');
return {
ok: true,
value: JSON.stringify({
candidates: [
{ ...candidate('ignored', stopped), candidate_id: undefined },
{ ...candidate('ignored', kept), candidate_id: undefined },
],
}),
latencyMs: 11,
};
}
const payload = JSON.parse(messages.at(-1)!.content) as {
candidates: { candidate_id: string; text: string }[];
};
verified.push(...payload.candidates.map((item) => item.text));
return {
ok: true,
value: JSON.stringify({
verdicts: payload.candidates.map((item) => ({
candidate_id: item.candidate_id,
source_selected_polarity: 'affirmed',
...retentionAudit(item, true, true, true),
proposition_supported: true,
action_arguments_preserved: true,
qualification_scope_preserved: true,
reason_code: null,
})),
}),
latencyMs: 22,
};
});
const model = {
available: true,
chat,
modelId: 'invented-curator',
reportInvalidResponse: vi.fn(),
} as unknown as ModelClient;

const result = await runRetain(source, model, { stopWords: ['Vulpine Mutual'] });
expect(verified).toEqual([kept]);
expect(result.candidates.map((item) => item.text)).toEqual([kept]);
expect(result.held).toMatchObject([{ reason_code: 'stop_word', hold_stage: 'validation' }]);
});
});
Loading
Loading