Knowledge relations can supply investigation clues before a refactor: nearby notes suggest checking callers, state, and persistence boundaries, then returning to source and fault tests. An edge is neither a call graph nor proof of a cause. The post-merge incident described in the original, its search scores, and colleague feedback lack reviewable evidence, so this article turns them into an explicit synthetic exercise.
Separate three graphs and scores
server/src/services/relations.ts takes semantic candidates or recent notes and asks a model for bounded relation types, descriptions, and confidence. It retains candidate targets with confidence at least 0.5, up to five edges. Manual relations store confidence 1. These values are suggestions, not calibrated causal probabilities; a manual 1 does not verify a dependency.
server/src/routes/relations.ts exposes both note-relation and tag-co-occurrence projections. Note projections select a visible subgraph by project, relation type, confidence, and a node limit, and may be truncated. Tag scores divide shared-note count by the square root of the product of the two tag note counts. The current client/src/pages/RelationGraph.tsx requests a level=tag graph with limit=120, then lists notes when a tag is clicked. The original centered Embedding-note view is not this page’s current default.
| Clue | Current meaning | What it does not establish |
|---|---|---|
| Search score | Vector candidates ranked with lexical matching, thresholds, and other logic | Failure probability or comparable confidence across queries |
| Relation confidence | Model suggestion or manually set relation field | Verified calls, state writes, or a causal chain |
| Tag co-occurrence score | Normalized tag co-occurrence in notes | Code modules or dependency direction |
Expansion is an investigation entry point
server/src/services/embedding.ts expands direct hits through one-hop note relations with confidence>=0.5. Linked notes must also have embedding_status=done. Their score multiplies the direct score, 0.7, and confidence. A note already present is not updated through a later path, and the final set may exceed topK. The 0.5 boundary is inclusive; ordinary expansion does not necessarily exclude that edge. See vector-query internals for the same implementation.
The current CLI search has no –expand option. After confirming the connection and authentication, use expand=true through the REST contract. This only illustrates the interface and was not run against real models or data:
curl --fail-with-body --get "http://localhost:3721/api/search" --data-urlencode "q=embedding search regression" --data-urlencode "expand=true" --data-urlencode "limit=5"
For cloud use, choose the reviewed HTTPS target and its authentication; the local tokenless form is not a cloud deployment plan. The REST search mapping returns note_id, conversation_id, and via_relation; the internal service field is named viaRelation. Follow note IDs to source content, relation descriptions, and created_by before selecting the next source check. via_relation is not a root-cause conclusion.
Audit the original explanation through control flow
The original sketch awaits Promise.all before beginUpdate. If that promise rejects, execution never opens an index update; an assignment to done after a throw also does not execute automatically. The same fragment cannot establish a model-request failure, a committed half-batch, and a done state together. Promise.all rejection does not cancel requests already started either.
Current server/src/services/embedding.ts obtains vectors sequentially before opening an index update. It inserts new items, swaps mappings and sets syncing in a SQL transaction, saves the database after the transaction, deletes old index items, calls endUpdate, then marks done and saves. The catch has two independent conditions: updateOpen first triggers an attempt to cancelUpdate; dbSwapped then triggers failed status and a save. Both can run during the same failure. Retries also check committed syncing items. This is not the original proposed partial-state scheme or a fully atomic transaction spanning SQLite and index files.
cancelQueue in server/src/queue/index.ts clears pending jobs and marks queued tasks failed. It does not interrupt the active job. Mid-run cancellation, process termination, and index-commit failure require separate real fault entry points; clearing pending jobs is not their substitute.
A synthetic investigation and ordering exercise
Save this as refactor.mjs under Node 24 and run node refactor.mjs. It collects questions through two synthetic note hops, then simulates request-stage and insertion-stage failures through a call trace. It does not run ChatCrystal search, the graph page, generateEmbeddings, a Provider, Vectra, or SQL. Its simulated cancel does not prove rollback of real disk side effects.
import assert from 'node:assert/strict';
// Synthetic note links identify questions, never prove code dependencies.
const edges = [
{ from: 'embedding', to: 'queue', type: 'SIMILAR_TO', confidence: 0.8 },
{ from: 'queue', to: 'index', type: 'REFERENCES', confidence: 0.5 },
{ from: 'queue', to: 'unrelated', type: 'SIMILAR_TO', confidence: 0.49 },
];
function candidates(start, maxDepth = 2) {
const seen = new Map([[start, 0]]);
let frontier = [start];
for (let depth = 1; depth <= maxDepth; depth++) {
const next = [];
for (const node of frontier) {
for (const edge of edges) {
if (edge.confidence < 0.5) continue;
const neighbor = edge.from === node ? edge.to : edge.to === node ? edge.from : null;
if (!neighbor || seen.has(neighbor)) continue;
seen.set(neighbor, depth);
next.push(neighbor);
}
}
frontier = next;
}
return seen;
}
const clues = candidates('embedding');
assert.equal(clues.get('index'), 2);
assert.equal(clues.has('unrelated'), false);
async function exercise(fault) {
const calls = [];
let done = false;
const embed = async (i) => {
calls.push(`embed:${i}`);
if (fault === 'embed' && i === 1) throw new Error('embed failed');
return [i, 1];
};
try {
const vectors = await Promise.all([0, 1].map(embed));
calls.push('beginUpdate');
try {
for (let i = 0; i < vectors.length; i++) {
calls.push(`insert:${i}`);
if (fault === 'insert' && i === 1) throw new Error('insert failed');
}
calls.push('endUpdate');
done = true;
} catch (error) {
calls.push('cancelUpdate');
throw error;
}
} catch (error) {
return { calls, done, error: error.message };
}
return { calls, done, error: null };
}
const beforeBegin = await exercise('embed');
assert.equal(beforeBegin.done, false);
assert.equal(beforeBegin.calls.includes('beginUpdate'), false);
assert.equal(beforeBegin.calls.includes('endUpdate'), false);
const duringInsert = await exercise('insert');
assert.equal(duringInsert.done, false);
assert.equal(duringInsert.calls.includes('cancelUpdate'), true);
assert.equal(duringInsert.calls.includes('endUpdate'), false);
const healthy = await exercise('none');
assert.equal(healthy.done, true);
assert.equal(healthy.calls.filter((x) => x === 'endUpdate').length, 1);
console.log(
'two-hop clues and simulated failure ordering pass; no product graph or index executes'
);
Turn clues into a reviewable refactor checklist
Change the confidence<0.5 exclusion to <=0.5: the second-hop assertion must fail. Replace cancelUpdate with endUpdate in the insertion-failure catch: the failure trace assertion must fail. Restore and rerun. These check this exercise’s edge selection and ordering, not a real call chain.
For the actual refactor, list whether callers await work, when state is saved, when old mappings are invalidated, what index-commit failure leaves behind, how reopening establishes searchability, how repeated requests behave, and which layer cancellation affects. Verify each with fixed fixtures and fault injection, then record results and the source version in notes. Relations propose questions; counterexamples, control flow, and recovery evidence establish causes. Continue with summary queues and index recovery.