@deepwatch/dsh-library 0.1.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/LICENSE +21 -0
- package/README.md +88 -0
- package/lib/client/components.d.ts +75 -0
- package/lib/client/components.js +60 -0
- package/lib/client/index.d.ts +46 -0
- package/lib/client/index.js +52 -0
- package/lib/client/library-mode.d.ts +37 -0
- package/lib/client/library-mode.js +21 -0
- package/lib/client/read-plane.d.ts +134 -0
- package/lib/client/read-plane.js +193 -0
- package/lib/client/search-view.d.ts +44 -0
- package/lib/client/search-view.js +233 -0
- package/lib/client.js +1882 -0
- package/lib/client.js.map +1 -0
- package/lib/index-store.d.ts +221 -0
- package/lib/index-store.js +570 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +20 -0
- package/lib/search.d.ts +129 -0
- package/lib/search.js +180 -0
- package/lib/sources.d.ts +146 -0
- package/lib/sources.js +135 -0
- package/package.json +101 -0
package/lib/search.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library search: finding the source, and saying how it was found.
|
|
3
|
+
*
|
|
4
|
+
* Search here has one unusual requirement. It has to report *which retrieval
|
|
5
|
+
* path produced a result*, because the two paths make different promises. A
|
|
6
|
+
* lexical hit means those characters are in that source at that moment. A
|
|
7
|
+
* semantic hit means something in that source was near the query in an
|
|
8
|
+
* embedding space, which is a much weaker claim and occasionally a wrong one.
|
|
9
|
+
*
|
|
10
|
+
* A product that merged them into one ranked list with one relevance number
|
|
11
|
+
* would be a product where "it found nothing" and "the embedding model was not
|
|
12
|
+
* installed" look identical, and where a paraphrase match and an exact quote
|
|
13
|
+
* look equally certain. So {@link searchPlan} states the path before anything
|
|
14
|
+
* runs, and every hit carries the path that produced it.
|
|
15
|
+
*
|
|
16
|
+
* Facets are computed from the results rather than from a fixed taxonomy. A
|
|
17
|
+
* facet that offers "Arabic (0)" on a library with no Arabic in it is a filter
|
|
18
|
+
* that teaches people the filter is broken.
|
|
19
|
+
*
|
|
20
|
+
* @module @deepwatch/dsh-library/search
|
|
21
|
+
*/
|
|
22
|
+
import type { TemporalRange } from '@deepwatch/dsh-contracts';
|
|
23
|
+
import type { Source, SourceKind, IndexState } from './sources.js';
|
|
24
|
+
/** How a result was retrieved. */
|
|
25
|
+
export type RetrievalPath = 'lexical' | 'semantic' | 'both';
|
|
26
|
+
/** What the engine can actually do here. */
|
|
27
|
+
export interface SearchCapabilities {
|
|
28
|
+
/** Substring and token matching over extracted text. */
|
|
29
|
+
readonly lexical: boolean;
|
|
30
|
+
/** Embedding retrieval. Requires a bound embeddings role. */
|
|
31
|
+
readonly semantic: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** What search will do, decided before it runs. */
|
|
34
|
+
export interface SearchPlan {
|
|
35
|
+
readonly path: RetrievalPath | 'none';
|
|
36
|
+
/** One sentence for the results header. Always populated. */
|
|
37
|
+
readonly explanation: string;
|
|
38
|
+
/** What is missing, and what to do about it. Empty when nothing is. */
|
|
39
|
+
readonly degradedBecause: string;
|
|
40
|
+
readonly fix: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Decide the retrieval path.
|
|
44
|
+
*
|
|
45
|
+
* Semantic-only is a real state and is reported as one rather than silently
|
|
46
|
+
* treated as "search works". A library where exact-phrase search is
|
|
47
|
+
* unavailable behaves very differently from one where it is not, and a user
|
|
48
|
+
* searching for an error code needs to know which they are in.
|
|
49
|
+
*/
|
|
50
|
+
export declare function searchPlan(capabilities: SearchCapabilities): SearchPlan;
|
|
51
|
+
/** One hit inside one source. */
|
|
52
|
+
export interface SearchHit {
|
|
53
|
+
readonly sourceId: string;
|
|
54
|
+
readonly sourceRevisionId: string;
|
|
55
|
+
/** Where in the source, when the modality has a clock. */
|
|
56
|
+
readonly range: TemporalRange | null;
|
|
57
|
+
/** The matched text, verbatim and in its original script. */
|
|
58
|
+
readonly text: string;
|
|
59
|
+
/** Which path produced this hit. */
|
|
60
|
+
readonly path: RetrievalPath;
|
|
61
|
+
/**
|
|
62
|
+
* Score, in the producing path's own units.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately not normalized across paths. A lexical rank and a cosine
|
|
65
|
+
* similarity are not comparable, and putting them on one 0–1 scale would
|
|
66
|
+
* manufacture a comparison that does not exist.
|
|
67
|
+
*/
|
|
68
|
+
readonly score: number;
|
|
69
|
+
/** Evidence this hit resolves to, when the engine minted any. */
|
|
70
|
+
readonly evidenceIds: readonly string[];
|
|
71
|
+
}
|
|
72
|
+
/** A result: one source, and the hits inside it. */
|
|
73
|
+
export interface SearchResult {
|
|
74
|
+
readonly sourceId: string;
|
|
75
|
+
readonly title: string;
|
|
76
|
+
readonly kind: SourceKind;
|
|
77
|
+
readonly hits: readonly SearchHit[];
|
|
78
|
+
/** Whether the hits are against the source's current revision. */
|
|
79
|
+
readonly current: boolean;
|
|
80
|
+
}
|
|
81
|
+
/** One facet value and how many results carry it. */
|
|
82
|
+
export interface FacetValue {
|
|
83
|
+
readonly value: string;
|
|
84
|
+
readonly count: number;
|
|
85
|
+
}
|
|
86
|
+
/** The facets computed from a result set. */
|
|
87
|
+
export interface Facets {
|
|
88
|
+
readonly kind: readonly FacetValue[];
|
|
89
|
+
readonly indexState: readonly FacetValue[];
|
|
90
|
+
readonly collection: readonly FacetValue[];
|
|
91
|
+
readonly script: readonly FacetValue[];
|
|
92
|
+
readonly path: readonly FacetValue[];
|
|
93
|
+
}
|
|
94
|
+
/** What a search was narrowed to. */
|
|
95
|
+
export interface SearchFilters {
|
|
96
|
+
readonly kinds?: readonly SourceKind[];
|
|
97
|
+
readonly collections?: readonly string[];
|
|
98
|
+
readonly indexStates?: readonly IndexState[];
|
|
99
|
+
readonly scripts?: readonly string[];
|
|
100
|
+
/** Only hits from the current revision of each source. */
|
|
101
|
+
readonly currentOnly?: boolean;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Compute facets over a result set.
|
|
105
|
+
*
|
|
106
|
+
* Only values that actually occur. A facet list generated from the schema
|
|
107
|
+
* rather than from the results offers filters that return nothing, and a
|
|
108
|
+
* filter that returns nothing is indistinguishable from a broken one.
|
|
109
|
+
*/
|
|
110
|
+
export declare function facetsFor(results: readonly SearchResult[], sources: readonly Source[]): Facets;
|
|
111
|
+
/** Apply filters to a result set. Pure; the engine does the retrieval. */
|
|
112
|
+
export declare function applyFilters(results: readonly SearchResult[], sources: readonly Source[], filters: SearchFilters): readonly SearchResult[];
|
|
113
|
+
/**
|
|
114
|
+
* Order results for display.
|
|
115
|
+
*
|
|
116
|
+
* Within a source, hits are ordered by path and then by time — not by score
|
|
117
|
+
* across paths, because the scores are not comparable. Across sources, the
|
|
118
|
+
* source with the strongest lexical evidence leads, because an exact match is
|
|
119
|
+
* the strongest claim search can make.
|
|
120
|
+
*/
|
|
121
|
+
export declare function rankResults(results: readonly SearchResult[]): readonly SearchResult[];
|
|
122
|
+
/**
|
|
123
|
+
* One line above the results, stating what was searched and how.
|
|
124
|
+
*
|
|
125
|
+
* Always says the path. "12 results" alone invites the reading that the library
|
|
126
|
+
* was searched thoroughly, which may not be true.
|
|
127
|
+
*/
|
|
128
|
+
export declare function describeSearch(plan: SearchPlan, results: readonly SearchResult[]): string;
|
|
129
|
+
//# sourceMappingURL=search.d.ts.map
|
package/lib/search.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library search: finding the source, and saying how it was found.
|
|
3
|
+
*
|
|
4
|
+
* Search here has one unusual requirement. It has to report *which retrieval
|
|
5
|
+
* path produced a result*, because the two paths make different promises. A
|
|
6
|
+
* lexical hit means those characters are in that source at that moment. A
|
|
7
|
+
* semantic hit means something in that source was near the query in an
|
|
8
|
+
* embedding space, which is a much weaker claim and occasionally a wrong one.
|
|
9
|
+
*
|
|
10
|
+
* A product that merged them into one ranked list with one relevance number
|
|
11
|
+
* would be a product where "it found nothing" and "the embedding model was not
|
|
12
|
+
* installed" look identical, and where a paraphrase match and an exact quote
|
|
13
|
+
* look equally certain. So {@link searchPlan} states the path before anything
|
|
14
|
+
* runs, and every hit carries the path that produced it.
|
|
15
|
+
*
|
|
16
|
+
* Facets are computed from the results rather than from a fixed taxonomy. A
|
|
17
|
+
* facet that offers "Arabic (0)" on a library with no Arabic in it is a filter
|
|
18
|
+
* that teaches people the filter is broken.
|
|
19
|
+
*
|
|
20
|
+
* @module @deepwatch/dsh-library/search
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Decide the retrieval path.
|
|
24
|
+
*
|
|
25
|
+
* Semantic-only is a real state and is reported as one rather than silently
|
|
26
|
+
* treated as "search works". A library where exact-phrase search is
|
|
27
|
+
* unavailable behaves very differently from one where it is not, and a user
|
|
28
|
+
* searching for an error code needs to know which they are in.
|
|
29
|
+
*/
|
|
30
|
+
export function searchPlan(capabilities) {
|
|
31
|
+
if (capabilities.lexical && capabilities.semantic) {
|
|
32
|
+
return {
|
|
33
|
+
path: 'both',
|
|
34
|
+
explanation: 'Hybrid search: exact matches and meaning-based matches, marked separately.',
|
|
35
|
+
degradedBecause: '',
|
|
36
|
+
fix: '',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (capabilities.lexical) {
|
|
40
|
+
return {
|
|
41
|
+
path: 'lexical',
|
|
42
|
+
explanation: 'Exact matching only. A paraphrase of what was said will not be found.',
|
|
43
|
+
degradedBecause: 'No embeddings role is bound, so semantic retrieval is unavailable.',
|
|
44
|
+
fix: 'Bind an embeddings role in Settings to search by meaning as well.',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (capabilities.semantic) {
|
|
48
|
+
return {
|
|
49
|
+
path: 'semantic',
|
|
50
|
+
explanation: 'Meaning-based matching only. An exact phrase may rank below a paraphrase.',
|
|
51
|
+
degradedBecause: 'The lexical index is unavailable.',
|
|
52
|
+
fix: 'Re-index the library to restore exact matching.',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
path: 'none',
|
|
57
|
+
explanation: 'Search is unavailable.',
|
|
58
|
+
degradedBecause: 'Neither the lexical index nor an embeddings role is available.',
|
|
59
|
+
fix: 'Index a source, or bind an embeddings role in Settings.',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Count values, dropping the empty ones. */
|
|
63
|
+
function tally(values) {
|
|
64
|
+
const counts = new Map();
|
|
65
|
+
for (const value of values)
|
|
66
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
67
|
+
return [...counts.entries()]
|
|
68
|
+
.map(([value, count]) => ({ value, count }))
|
|
69
|
+
.sort((left, right) => {
|
|
70
|
+
const byCount = right.count - left.count;
|
|
71
|
+
return byCount !== 0 ? byCount : left.value.localeCompare(right.value);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Compute facets over a result set.
|
|
76
|
+
*
|
|
77
|
+
* Only values that actually occur. A facet list generated from the schema
|
|
78
|
+
* rather than from the results offers filters that return nothing, and a
|
|
79
|
+
* filter that returns nothing is indistinguishable from a broken one.
|
|
80
|
+
*/
|
|
81
|
+
export function facetsFor(results, sources) {
|
|
82
|
+
const byId = new Map(sources.map(source => [source.sourceId, source]));
|
|
83
|
+
const kinds = [];
|
|
84
|
+
const indexStates = [];
|
|
85
|
+
const collections = [];
|
|
86
|
+
const scripts = [];
|
|
87
|
+
const paths = [];
|
|
88
|
+
for (const result of results) {
|
|
89
|
+
kinds.push(result.kind);
|
|
90
|
+
const source = byId.get(result.sourceId);
|
|
91
|
+
if (source !== undefined) {
|
|
92
|
+
for (const collection of source.collections)
|
|
93
|
+
collections.push(collection);
|
|
94
|
+
for (const revision of source.revisions) {
|
|
95
|
+
if (!result.hits.some(hit => hit.sourceRevisionId === revision.sourceRevisionId))
|
|
96
|
+
continue;
|
|
97
|
+
indexStates.push(revision.indexState);
|
|
98
|
+
for (const script of revision.scripts)
|
|
99
|
+
scripts.push(script);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const hit of result.hits)
|
|
103
|
+
paths.push(hit.path);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
kind: tally(kinds),
|
|
107
|
+
indexState: tally(indexStates),
|
|
108
|
+
collection: tally(collections),
|
|
109
|
+
script: tally(scripts),
|
|
110
|
+
path: tally(paths),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Apply filters to a result set. Pure; the engine does the retrieval. */
|
|
114
|
+
export function applyFilters(results, sources, filters) {
|
|
115
|
+
const byId = new Map(sources.map(source => [source.sourceId, source]));
|
|
116
|
+
return results.filter(result => {
|
|
117
|
+
if (filters.kinds !== undefined && !filters.kinds.includes(result.kind))
|
|
118
|
+
return false;
|
|
119
|
+
if (filters.currentOnly === true && !result.current)
|
|
120
|
+
return false;
|
|
121
|
+
const source = byId.get(result.sourceId);
|
|
122
|
+
if (filters.collections !== undefined) {
|
|
123
|
+
if (source === undefined)
|
|
124
|
+
return false;
|
|
125
|
+
if (!filters.collections.some(collection => source.collections.includes(collection)))
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (filters.indexStates !== undefined) {
|
|
129
|
+
if (source === undefined)
|
|
130
|
+
return false;
|
|
131
|
+
const states = source.revisions
|
|
132
|
+
.filter(revision => result.hits.some(hit => hit.sourceRevisionId === revision.sourceRevisionId))
|
|
133
|
+
.map(revision => revision.indexState);
|
|
134
|
+
if (!states.some(state => filters.indexStates?.includes(state) === true))
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
if (filters.scripts !== undefined) {
|
|
138
|
+
if (source === undefined)
|
|
139
|
+
return false;
|
|
140
|
+
const present = new Set(source.revisions.flatMap(revision => revision.scripts));
|
|
141
|
+
if (!filters.scripts.some(script => present.has(script)))
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Order results for display.
|
|
149
|
+
*
|
|
150
|
+
* Within a source, hits are ordered by path and then by time — not by score
|
|
151
|
+
* across paths, because the scores are not comparable. Across sources, the
|
|
152
|
+
* source with the strongest lexical evidence leads, because an exact match is
|
|
153
|
+
* the strongest claim search can make.
|
|
154
|
+
*/
|
|
155
|
+
export function rankResults(results) {
|
|
156
|
+
const lexicalWeight = (result) => result.hits.filter(hit => hit.path === 'lexical' || hit.path === 'both').length;
|
|
157
|
+
return [...results].sort((left, right) => {
|
|
158
|
+
const byLexical = lexicalWeight(right) - lexicalWeight(left);
|
|
159
|
+
if (byLexical !== 0)
|
|
160
|
+
return byLexical;
|
|
161
|
+
const byHits = right.hits.length - left.hits.length;
|
|
162
|
+
if (byHits !== 0)
|
|
163
|
+
return byHits;
|
|
164
|
+
return left.sourceId.localeCompare(right.sourceId);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* One line above the results, stating what was searched and how.
|
|
169
|
+
*
|
|
170
|
+
* Always says the path. "12 results" alone invites the reading that the library
|
|
171
|
+
* was searched thoroughly, which may not be true.
|
|
172
|
+
*/
|
|
173
|
+
export function describeSearch(plan, results) {
|
|
174
|
+
const hits = results.reduce((total, result) => total + result.hits.length, 0);
|
|
175
|
+
const count = `${String(hits)} hit(s) in ${String(results.length)} source(s)`;
|
|
176
|
+
return plan.degradedBecause === ''
|
|
177
|
+
? `${count} · ${plan.explanation}`
|
|
178
|
+
: `${count} · ${plan.explanation} ${plan.degradedBecause}`;
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=search.js.map
|
package/lib/sources.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Library: sources, their revisions, and what is still true about them.
|
|
3
|
+
*
|
|
4
|
+
* The Library is not memory. That separation is the first thing this module
|
|
5
|
+
* exists to hold: memory is what the system believes, and the Library is what
|
|
6
|
+
* it has *seen*. Conflating them produces the worst version of both — a
|
|
7
|
+
* knowledge base you cannot cite and an evidence store that argues with you.
|
|
8
|
+
* So nothing here has a scope, a confidence or a status; those are memory's
|
|
9
|
+
* vocabulary. A source has revisions, and evidence is addressed to one of them.
|
|
10
|
+
*
|
|
11
|
+
* The second thing it holds is the revision rule, which is the whole reason
|
|
12
|
+
* evidence ids are worth anything:
|
|
13
|
+
*
|
|
14
|
+
* > A source that changed is a different source revision, and evidence stays
|
|
15
|
+
* > addressed to the revision it was taken from — forever.
|
|
16
|
+
*
|
|
17
|
+
* When a page is re-indexed, the evidence from last week does not become
|
|
18
|
+
* wrong, and it does not become unreachable. It becomes *stale*: it still
|
|
19
|
+
* opens, still resolves to the same frame at the same millisecond, and now
|
|
20
|
+
* carries a note saying the source has moved on. Deleting it would destroy the
|
|
21
|
+
* receipt; silently re-pointing it at the new revision would be worse, because
|
|
22
|
+
* the citation would then be to something nobody observed.
|
|
23
|
+
*
|
|
24
|
+
* @module @deepwatch/dsh-library/sources
|
|
25
|
+
*/
|
|
26
|
+
import type { EvidenceRecord, Freshness, TemporalRange } from '@deepwatch/dsh-contracts';
|
|
27
|
+
import type { ScriptTag } from '@deepwatch/dsh-technology';
|
|
28
|
+
/** What kind of thing a source is. */
|
|
29
|
+
export type SourceKind = 'video' | 'audio' | 'page' | 'stream' | 'document' | 'screen_capture';
|
|
30
|
+
/** Where an index is in its life. */
|
|
31
|
+
export type IndexState =
|
|
32
|
+
/** Known to the Library, nothing extracted. */
|
|
33
|
+
'not_indexed' | 'indexing'
|
|
34
|
+
/** Extracted and searchable. */
|
|
35
|
+
| 'indexed'
|
|
36
|
+
/** Indexed, but against an older revision than the current one. */
|
|
37
|
+
| 'stale' | 'failed';
|
|
38
|
+
/** One immutable version of a source. */
|
|
39
|
+
export interface SourceRevision {
|
|
40
|
+
readonly sourceRevisionId: string;
|
|
41
|
+
readonly sourceId: string;
|
|
42
|
+
/** Monotonic within a source. Revision 1 is the first thing observed. */
|
|
43
|
+
readonly revision: number;
|
|
44
|
+
/** Digest of the bytes, which is what makes "changed" a fact. */
|
|
45
|
+
readonly contentDigest: string;
|
|
46
|
+
readonly observedAt: string;
|
|
47
|
+
readonly durationMs: number | null;
|
|
48
|
+
readonly indexState: IndexState;
|
|
49
|
+
/** Why indexing failed, when it did. */
|
|
50
|
+
readonly indexError: string | null;
|
|
51
|
+
/** Scripts detected in this revision's text. Structural, not measured. */
|
|
52
|
+
readonly scripts: readonly ScriptTag[];
|
|
53
|
+
}
|
|
54
|
+
/** A source, with every revision the Library holds. */
|
|
55
|
+
export interface Source {
|
|
56
|
+
readonly sourceId: string;
|
|
57
|
+
readonly kind: SourceKind;
|
|
58
|
+
/** What it is called. Presentation only; nothing resolves from it. */
|
|
59
|
+
readonly title: string;
|
|
60
|
+
/** URL or path. */
|
|
61
|
+
readonly locator: string;
|
|
62
|
+
readonly revisions: readonly SourceRevision[];
|
|
63
|
+
/** Collections this source belongs to. */
|
|
64
|
+
readonly collections: readonly string[];
|
|
65
|
+
/** Entities extracted from it, when the engine extracts any. */
|
|
66
|
+
readonly entities: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
/** A named group of sources. Curation, not classification. */
|
|
69
|
+
export interface Collection {
|
|
70
|
+
readonly collectionId: string;
|
|
71
|
+
readonly name: string;
|
|
72
|
+
readonly sourceIds: readonly string[];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The current revision of a source.
|
|
76
|
+
*
|
|
77
|
+
* Highest revision number, not most recently observed — a re-index of an old
|
|
78
|
+
* revision must not become "current" because it happened last.
|
|
79
|
+
*/
|
|
80
|
+
export declare function currentRevision(source: Source): SourceRevision | null;
|
|
81
|
+
/** Find one revision by id, wherever it sits in the history. */
|
|
82
|
+
export declare function findRevision(source: Source, sourceRevisionId: string): SourceRevision | null;
|
|
83
|
+
/**
|
|
84
|
+
* Whether a source revision is still the one a fresh observation would produce.
|
|
85
|
+
*
|
|
86
|
+
* Separated from freshness below because they are different questions: this is
|
|
87
|
+
* about the *source*, and freshness is about one piece of evidence taken from
|
|
88
|
+
* it. A source can be current while a specific observation from it is stale,
|
|
89
|
+
* when the observation covers a range the new revision no longer contains.
|
|
90
|
+
*/
|
|
91
|
+
export declare function isCurrentRevision(source: Source, sourceRevisionId: string): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Freshness of one evidence record, given what the Library now holds.
|
|
94
|
+
*
|
|
95
|
+
* The rules, in order:
|
|
96
|
+
*
|
|
97
|
+
* - Evidence whose source the Library does not hold is `unavailable`. Not
|
|
98
|
+
* `expired` — nobody knows whether it expired; it simply cannot be checked.
|
|
99
|
+
* - Evidence against the current revision keeps whatever freshness the engine
|
|
100
|
+
* assigned it, including `gap`. Freshness is not the Library's to upgrade.
|
|
101
|
+
* - Evidence against a superseded revision is `stale`. It still resolves; it
|
|
102
|
+
* no longer describes the source.
|
|
103
|
+
*
|
|
104
|
+
* Note what this function never returns: `current` for something it was not
|
|
105
|
+
* already told was current. A Library that could promote evidence to fresh
|
|
106
|
+
* would be a Library that re-validates by assertion.
|
|
107
|
+
*/
|
|
108
|
+
export declare function freshnessOf(evidence: Pick<EvidenceRecord, 'sourceRevisionId' | 'freshness'>, sources: readonly Source[]): Freshness;
|
|
109
|
+
/**
|
|
110
|
+
* Whether an evidence id can still be opened.
|
|
111
|
+
*
|
|
112
|
+
* Always true when the Library holds its revision, whatever the freshness. The
|
|
113
|
+
* function exists to make that a stated guarantee rather than an accident of
|
|
114
|
+
* whichever query happens to run: a stale citation that stopped opening would
|
|
115
|
+
* turn every old receipt into a dead link.
|
|
116
|
+
*/
|
|
117
|
+
export declare function isAddressable(evidence: Pick<EvidenceRecord, 'sourceRevisionId'>, sources: readonly Source[]): boolean;
|
|
118
|
+
/** A place in a source that a citation resolves to. */
|
|
119
|
+
export interface EvidenceLocation {
|
|
120
|
+
readonly sourceId: string;
|
|
121
|
+
readonly sourceRevisionId: string;
|
|
122
|
+
readonly revision: number;
|
|
123
|
+
readonly range: TemporalRange | null;
|
|
124
|
+
readonly freshness: Freshness;
|
|
125
|
+
/** Whether the source has moved on since this was observed. */
|
|
126
|
+
readonly supersededBy: string | null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolve an evidence record to a place in the Library.
|
|
130
|
+
*
|
|
131
|
+
* Returns null only when the revision is not held. Everything else resolves,
|
|
132
|
+
* including evidence from four revisions ago — with `supersededBy` naming what
|
|
133
|
+
* replaced it, so the surface can offer "look at the same moment in the
|
|
134
|
+
* current revision" without silently doing it.
|
|
135
|
+
*/
|
|
136
|
+
export declare function locate(evidence: Pick<EvidenceRecord, 'sourceRevisionId' | 'temporalRange' | 'freshness'>, sources: readonly Source[]): EvidenceLocation | null;
|
|
137
|
+
/**
|
|
138
|
+
* Record a new revision of a source.
|
|
139
|
+
*
|
|
140
|
+
* Old revisions are kept, and their index state is marked `stale` rather than
|
|
141
|
+
* removed. That is the mechanism behind every "old evidence still opens"
|
|
142
|
+
* guarantee above — there is no code path that discards a revision, so there is
|
|
143
|
+
* no code path that could orphan a citation.
|
|
144
|
+
*/
|
|
145
|
+
export declare function withRevision(source: Source, revision: SourceRevision): Source;
|
|
146
|
+
//# sourceMappingURL=sources.d.ts.map
|
package/lib/sources.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Library: sources, their revisions, and what is still true about them.
|
|
3
|
+
*
|
|
4
|
+
* The Library is not memory. That separation is the first thing this module
|
|
5
|
+
* exists to hold: memory is what the system believes, and the Library is what
|
|
6
|
+
* it has *seen*. Conflating them produces the worst version of both — a
|
|
7
|
+
* knowledge base you cannot cite and an evidence store that argues with you.
|
|
8
|
+
* So nothing here has a scope, a confidence or a status; those are memory's
|
|
9
|
+
* vocabulary. A source has revisions, and evidence is addressed to one of them.
|
|
10
|
+
*
|
|
11
|
+
* The second thing it holds is the revision rule, which is the whole reason
|
|
12
|
+
* evidence ids are worth anything:
|
|
13
|
+
*
|
|
14
|
+
* > A source that changed is a different source revision, and evidence stays
|
|
15
|
+
* > addressed to the revision it was taken from — forever.
|
|
16
|
+
*
|
|
17
|
+
* When a page is re-indexed, the evidence from last week does not become
|
|
18
|
+
* wrong, and it does not become unreachable. It becomes *stale*: it still
|
|
19
|
+
* opens, still resolves to the same frame at the same millisecond, and now
|
|
20
|
+
* carries a note saying the source has moved on. Deleting it would destroy the
|
|
21
|
+
* receipt; silently re-pointing it at the new revision would be worse, because
|
|
22
|
+
* the citation would then be to something nobody observed.
|
|
23
|
+
*
|
|
24
|
+
* @module @deepwatch/dsh-library/sources
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* The current revision of a source.
|
|
28
|
+
*
|
|
29
|
+
* Highest revision number, not most recently observed — a re-index of an old
|
|
30
|
+
* revision must not become "current" because it happened last.
|
|
31
|
+
*/
|
|
32
|
+
export function currentRevision(source) {
|
|
33
|
+
let best = null;
|
|
34
|
+
for (const revision of source.revisions) {
|
|
35
|
+
if (best === null || revision.revision > best.revision)
|
|
36
|
+
best = revision;
|
|
37
|
+
}
|
|
38
|
+
return best;
|
|
39
|
+
}
|
|
40
|
+
/** Find one revision by id, wherever it sits in the history. */
|
|
41
|
+
export function findRevision(source, sourceRevisionId) {
|
|
42
|
+
return source.revisions.find(revision => revision.sourceRevisionId === sourceRevisionId) ?? null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether a source revision is still the one a fresh observation would produce.
|
|
46
|
+
*
|
|
47
|
+
* Separated from freshness below because they are different questions: this is
|
|
48
|
+
* about the *source*, and freshness is about one piece of evidence taken from
|
|
49
|
+
* it. A source can be current while a specific observation from it is stale,
|
|
50
|
+
* when the observation covers a range the new revision no longer contains.
|
|
51
|
+
*/
|
|
52
|
+
export function isCurrentRevision(source, sourceRevisionId) {
|
|
53
|
+
return currentRevision(source)?.sourceRevisionId === sourceRevisionId;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Freshness of one evidence record, given what the Library now holds.
|
|
57
|
+
*
|
|
58
|
+
* The rules, in order:
|
|
59
|
+
*
|
|
60
|
+
* - Evidence whose source the Library does not hold is `unavailable`. Not
|
|
61
|
+
* `expired` — nobody knows whether it expired; it simply cannot be checked.
|
|
62
|
+
* - Evidence against the current revision keeps whatever freshness the engine
|
|
63
|
+
* assigned it, including `gap`. Freshness is not the Library's to upgrade.
|
|
64
|
+
* - Evidence against a superseded revision is `stale`. It still resolves; it
|
|
65
|
+
* no longer describes the source.
|
|
66
|
+
*
|
|
67
|
+
* Note what this function never returns: `current` for something it was not
|
|
68
|
+
* already told was current. A Library that could promote evidence to fresh
|
|
69
|
+
* would be a Library that re-validates by assertion.
|
|
70
|
+
*/
|
|
71
|
+
export function freshnessOf(evidence, sources) {
|
|
72
|
+
const owner = sources.find(source => source.revisions.some(revision => revision.sourceRevisionId === evidence.sourceRevisionId));
|
|
73
|
+
if (owner === undefined)
|
|
74
|
+
return 'unavailable';
|
|
75
|
+
if (!isCurrentRevision(owner, evidence.sourceRevisionId))
|
|
76
|
+
return 'stale';
|
|
77
|
+
return evidence.freshness;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether an evidence id can still be opened.
|
|
81
|
+
*
|
|
82
|
+
* Always true when the Library holds its revision, whatever the freshness. The
|
|
83
|
+
* function exists to make that a stated guarantee rather than an accident of
|
|
84
|
+
* whichever query happens to run: a stale citation that stopped opening would
|
|
85
|
+
* turn every old receipt into a dead link.
|
|
86
|
+
*/
|
|
87
|
+
export function isAddressable(evidence, sources) {
|
|
88
|
+
return sources.some(source => source.revisions.some(revision => revision.sourceRevisionId === evidence.sourceRevisionId));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve an evidence record to a place in the Library.
|
|
92
|
+
*
|
|
93
|
+
* Returns null only when the revision is not held. Everything else resolves,
|
|
94
|
+
* including evidence from four revisions ago — with `supersededBy` naming what
|
|
95
|
+
* replaced it, so the surface can offer "look at the same moment in the
|
|
96
|
+
* current revision" without silently doing it.
|
|
97
|
+
*/
|
|
98
|
+
export function locate(evidence, sources) {
|
|
99
|
+
for (const source of sources) {
|
|
100
|
+
const revision = findRevision(source, evidence.sourceRevisionId);
|
|
101
|
+
if (revision === null)
|
|
102
|
+
continue;
|
|
103
|
+
const current = currentRevision(source);
|
|
104
|
+
return {
|
|
105
|
+
sourceId: source.sourceId,
|
|
106
|
+
sourceRevisionId: revision.sourceRevisionId,
|
|
107
|
+
revision: revision.revision,
|
|
108
|
+
range: evidence.temporalRange,
|
|
109
|
+
freshness: freshnessOf(evidence, sources),
|
|
110
|
+
supersededBy: current === null || current.sourceRevisionId === revision.sourceRevisionId
|
|
111
|
+
? null
|
|
112
|
+
: current.sourceRevisionId,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Record a new revision of a source.
|
|
119
|
+
*
|
|
120
|
+
* Old revisions are kept, and their index state is marked `stale` rather than
|
|
121
|
+
* removed. That is the mechanism behind every "old evidence still opens"
|
|
122
|
+
* guarantee above — there is no code path that discards a revision, so there is
|
|
123
|
+
* no code path that could orphan a citation.
|
|
124
|
+
*/
|
|
125
|
+
export function withRevision(source, revision) {
|
|
126
|
+
const existing = source.revisions.filter(entry => entry.sourceRevisionId !== revision.sourceRevisionId);
|
|
127
|
+
const superseded = existing.map(entry => entry.revision < revision.revision && entry.indexState === 'indexed'
|
|
128
|
+
? { ...entry, indexState: 'stale' }
|
|
129
|
+
: entry);
|
|
130
|
+
return {
|
|
131
|
+
...source,
|
|
132
|
+
revisions: [...superseded, revision].sort((left, right) => left.revision - right.revision),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=sources.js.map
|