@mark1russell7/netdiag 0.0.0-stage → 0.1.1
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 +244 -2
- package/build/address.d.ts +56 -0
- package/build/address.d.ts.map +1 -0
- package/build/address.js +172 -0
- package/build/address.js.map +1 -0
- package/build/diagnosis-a4hRzFxp.js +922 -0
- package/build/diagnosis-a4hRzFxp.js.map +1 -0
- package/build/diagnosis.d.ts +8 -0
- package/build/diagnosis.js +1 -0
- package/build/index-K2dGiYxW.d.ts +259 -0
- package/build/index-K2dGiYxW.d.ts.map +1 -0
- package/build/index.d.ts +392 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +1359 -0
- package/build/index.js.map +1 -0
- package/build/kinds-CQv769lK.js +341 -0
- package/build/kinds-CQv769lK.js.map +1 -0
- package/build/kinds-_NShfZpU.d.ts +377 -0
- package/build/kinds-_NShfZpU.d.ts.map +1 -0
- package/build/layer-BNg0VYuK.d.ts +22 -0
- package/build/layer-BNg0VYuK.d.ts.map +1 -0
- package/build/tools.d.ts +206 -0
- package/build/tools.d.ts.map +1 -0
- package/build/tools.js +896 -0
- package/build/tools.js.map +1 -0
- package/package.json +81 -3
- package/src/address/index.ts +155 -0
- package/src/diagnosis/catalog.ts +357 -0
- package/src/diagnosis/index.ts +47 -0
- package/src/diagnosis/model.ts +322 -0
- package/src/diagnosis/score.ts +160 -0
- package/src/fact.ts +87 -0
- package/src/index.ts +68 -0
- package/src/kinds.ts +460 -0
- package/src/layer.ts +31 -0
- package/src/probe.ts +61 -0
- package/src/probes/native.ts +354 -0
- package/src/probes/socket.ts +520 -0
- package/src/readers.ts +367 -0
- package/src/run.ts +163 -0
- package/src/scope.ts +152 -0
- package/src/target.ts +116 -0
- package/src/tools/common.ts +47 -0
- package/src/tools/host.ts +91 -0
- package/src/tools/index.ts +38 -0
- package/src/tools/licenses.ts +37 -0
- package/src/tools/mtr.ts +156 -0
- package/src/tools/ping.ts +231 -0
- package/src/tools/traceroute.ts +313 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { err, ok, type Result } from "@mark1russell7/optional";
|
|
2
|
+
import type { Layer } from "../layer.ts";
|
|
3
|
+
|
|
4
|
+
/** A probability for each value of a feature. The probabilities are more than 0, and their sum is 1. */
|
|
5
|
+
export type Distribution = Readonly<Record<string, number>>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A feature is one discrete observation that the facts give, for example the outcome of a TCP connect.
|
|
9
|
+
*
|
|
10
|
+
* The distribution `healthy` gives the probability of each value when the network has no fault.
|
|
11
|
+
*/
|
|
12
|
+
export interface Feature {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly layer: Layer;
|
|
15
|
+
readonly summary: string;
|
|
16
|
+
readonly values: readonly string[];
|
|
17
|
+
readonly healthy: Distribution;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The parts of the model. Each fault is in one part. */
|
|
21
|
+
export const PARTS = ["interface", "wifi", "gateway", "resolver", "path", "service"] as const;
|
|
22
|
+
|
|
23
|
+
/** A part of the model: the network interface, the Wi-Fi link, the gateway, the DNS resolver, the path or the remote service. */
|
|
24
|
+
export type Part = (typeof PARTS)[number];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The reach of a fault.
|
|
28
|
+
*
|
|
29
|
+
* - `shared`: the fault changes the observations of each host, for example a TLS proxy of the local network.
|
|
30
|
+
* - `target`: the fault changes only the observations of the primary target, for example a slow server.
|
|
31
|
+
*/
|
|
32
|
+
export type Reach = "shared" | "target";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The strength of a prediction. The rules of practice use these strengths. The lab runs then replace them with fitted
|
|
36
|
+
* distributions.
|
|
37
|
+
*/
|
|
38
|
+
export type Strength = "sure" | "strong" | "usual" | "weak";
|
|
39
|
+
|
|
40
|
+
/** The probability that each strength gives to the expected values together. */
|
|
41
|
+
export const STRENGTHS: Readonly<Record<Strength, number>> = { sure: 0.98, strong: 0.9, usual: 0.75, weak: 0.6 };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A prediction of a fault for one feature: a full distribution, or the expected values and a strength.
|
|
45
|
+
*
|
|
46
|
+
* With `expect`, the expected values share the probability of the strength equally. The other values share the rest.
|
|
47
|
+
*/
|
|
48
|
+
export type Prediction = Distribution | Expectation;
|
|
49
|
+
|
|
50
|
+
/** The expected values of a feature, and the strength of the prediction. */
|
|
51
|
+
export interface Expectation {
|
|
52
|
+
readonly expect: string | readonly string[];
|
|
53
|
+
readonly strength: Strength;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isExpectation(prediction: Prediction): prediction is Expectation {
|
|
57
|
+
return typeof prediction.strength === "string";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A fault of the catalog.
|
|
62
|
+
*
|
|
63
|
+
* The fault predicts a distribution for each feature that it changes. For each other feature, it predicts the
|
|
64
|
+
* distribution `healthy` of the feature. The prior is a relative weight. The default prior is 1.
|
|
65
|
+
*/
|
|
66
|
+
export interface Fault {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly part: Part;
|
|
69
|
+
readonly reach: Reach;
|
|
70
|
+
/** The cause in plain words, for example "No program listens on the port." */
|
|
71
|
+
readonly summary: string;
|
|
72
|
+
/** The next step for the user in plain words. */
|
|
73
|
+
readonly step: string;
|
|
74
|
+
readonly prior?: number | undefined;
|
|
75
|
+
readonly predicts: Readonly<Record<string, Prediction>>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The data of a catalog: the features, the faults, and the priors of the hypotheses "no fault" and "unknown cause". */
|
|
79
|
+
export interface CatalogSpec {
|
|
80
|
+
readonly version: string;
|
|
81
|
+
readonly features: readonly Feature[];
|
|
82
|
+
readonly faults: readonly Fault[];
|
|
83
|
+
readonly priors?: { readonly none?: number | undefined; readonly unknown?: number | undefined } | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A hypothesis of the model.
|
|
88
|
+
*
|
|
89
|
+
* - `none`: the network has no fault.
|
|
90
|
+
* - `fault`: one fault of the catalog.
|
|
91
|
+
* - `unknown`: a cause that the catalog does not have. It gives each value of a feature the same probability.
|
|
92
|
+
*/
|
|
93
|
+
export interface Hypothesis {
|
|
94
|
+
readonly id: string;
|
|
95
|
+
readonly kind: "none" | "fault" | "unknown";
|
|
96
|
+
readonly summary: string;
|
|
97
|
+
readonly step: string;
|
|
98
|
+
readonly part?: Part | undefined;
|
|
99
|
+
readonly reach?: Reach | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The id of the hypothesis "no fault". */
|
|
103
|
+
export const NONE = "none";
|
|
104
|
+
/** The id of the hypothesis "unknown cause". */
|
|
105
|
+
export const UNKNOWN = "unknown";
|
|
106
|
+
|
|
107
|
+
/** A catalog with each distribution in its full form, and the logarithms of the probabilities. */
|
|
108
|
+
export interface Catalog {
|
|
109
|
+
readonly version: string;
|
|
110
|
+
readonly features: ReadonlyMap<string, Feature>;
|
|
111
|
+
readonly hypotheses: readonly Hypothesis[];
|
|
112
|
+
/** This function gives the probability of a value of a feature under a hypothesis, for a primary or a reference host. */
|
|
113
|
+
likelihood(hypothesis: string, feature: string, value: string, primary: boolean): number;
|
|
114
|
+
prior(hypothesis: string): number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The lowest probability of a value. With a probability of 0, one wrong observation removes a cause. */
|
|
118
|
+
export const FLOOR = 0.001;
|
|
119
|
+
|
|
120
|
+
const SUM_TOLERANCE = 1e-9;
|
|
121
|
+
|
|
122
|
+
function distributionProblems(where: string, feature: Feature, dist: Distribution): string[] {
|
|
123
|
+
const problems: string[] = [];
|
|
124
|
+
for (const value of Object.keys(dist)) if (!feature.values.includes(value)) problems.push(`${where}: "${value}" is not a value of ${feature.id}.`);
|
|
125
|
+
for (const value of feature.values) {
|
|
126
|
+
const p = dist[value];
|
|
127
|
+
if (p === undefined) problems.push(`${where}: the value "${value}" of ${feature.id} has no probability.`);
|
|
128
|
+
else if (!Number.isFinite(p) || p < FLOOR) problems.push(`${where}: the probability of "${value}" is ${p}. It must be ${FLOOR} or more.`);
|
|
129
|
+
}
|
|
130
|
+
const sum = Object.values(dist).reduce((a, b) => a + b, 0);
|
|
131
|
+
if (Math.abs(sum - 1) > SUM_TOLERANCE) problems.push(`${where}: the sum of the probabilities is ${sum}, not 1.`);
|
|
132
|
+
return problems;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** This function gives the full distribution of a prediction, or the problems of the prediction. */
|
|
136
|
+
export function expand(feature: Feature, prediction: Prediction): Result<Distribution, string[]> {
|
|
137
|
+
if (!isExpectation(prediction)) return ok(prediction);
|
|
138
|
+
const expected = typeof prediction.expect === "string" ? [prediction.expect] : [...prediction.expect];
|
|
139
|
+
const unknown = expected.filter((value) => !feature.values.includes(value));
|
|
140
|
+
if (unknown.length > 0) return err(unknown.map((value) => `"${value}" is not a value of ${feature.id}.`));
|
|
141
|
+
if (expected.length === 0 || new Set(expected).size !== expected.length) return err([`The expected values of ${feature.id} are empty or repeated.`]);
|
|
142
|
+
const others = feature.values.filter((value) => !expected.includes(value));
|
|
143
|
+
const mass = others.length === 0 ? 1 : STRENGTHS[prediction.strength];
|
|
144
|
+
const dist: Record<string, number> = {};
|
|
145
|
+
for (const value of expected) dist[value] = mass / expected.length;
|
|
146
|
+
for (const value of others) dist[value] = (1 - mass) / others.length;
|
|
147
|
+
return ok(dist);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* This function examines a catalog and makes its tables.
|
|
154
|
+
*
|
|
155
|
+
* It refuses a repeated id, an unknown feature or value, a probability below `FLOOR`, and a distribution with a sum that is not 1.
|
|
156
|
+
*/
|
|
157
|
+
export function compileCatalog(spec: CatalogSpec): Result<Catalog, string[]> {
|
|
158
|
+
const problems: string[] = [];
|
|
159
|
+
const features = new Map<string, Feature>();
|
|
160
|
+
for (const feature of spec.features) {
|
|
161
|
+
if (!ID.test(feature.id)) problems.push(`"${feature.id}" is not a valid feature id.`);
|
|
162
|
+
if (features.has(feature.id)) problems.push(`The feature ${feature.id} is repeated.`);
|
|
163
|
+
if (feature.values.length < 2 || new Set(feature.values).size !== feature.values.length) {
|
|
164
|
+
problems.push(`The feature ${feature.id} must have two or more different values.`);
|
|
165
|
+
}
|
|
166
|
+
problems.push(...distributionProblems(`healthy of ${feature.id}`, feature, feature.healthy));
|
|
167
|
+
features.set(feature.id, feature);
|
|
168
|
+
}
|
|
169
|
+
const hypotheses: Hypothesis[] = [{ id: NONE, kind: "none", summary: "The network has no fault.", step: "No step is necessary." }];
|
|
170
|
+
const priors = new Map<string, number>([[NONE, spec.priors?.none ?? 1]]);
|
|
171
|
+
const tables = new Map<string, Map<string, Distribution>>();
|
|
172
|
+
for (const fault of spec.faults) {
|
|
173
|
+
if (!ID.test(fault.id) || fault.id === NONE || fault.id === UNKNOWN) problems.push(`"${fault.id}" is not a valid fault id.`);
|
|
174
|
+
if (priors.has(fault.id)) problems.push(`The fault ${fault.id} is repeated.`);
|
|
175
|
+
priors.set(fault.id, fault.prior ?? 1);
|
|
176
|
+
hypotheses.push({ id: fault.id, kind: "fault", summary: fault.summary, step: fault.step, part: fault.part, reach: fault.reach });
|
|
177
|
+
const table = new Map<string, Distribution>();
|
|
178
|
+
for (const [featureId, prediction] of Object.entries(fault.predicts)) {
|
|
179
|
+
const feature = features.get(featureId);
|
|
180
|
+
if (feature === undefined) {
|
|
181
|
+
problems.push(`The fault ${fault.id} predicts the unknown feature ${featureId}.`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const dist = expand(feature, prediction);
|
|
185
|
+
if (dist.tag === "err") problems.push(...dist.error.map((problem) => `${fault.id}: ${problem}`));
|
|
186
|
+
else {
|
|
187
|
+
problems.push(...distributionProblems(`${fault.id} for ${featureId}`, feature, dist.value));
|
|
188
|
+
table.set(featureId, dist.value);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (table.size === 0) problems.push(`The fault ${fault.id} predicts no feature.`);
|
|
192
|
+
tables.set(fault.id, table);
|
|
193
|
+
}
|
|
194
|
+
hypotheses.push({
|
|
195
|
+
id: UNKNOWN,
|
|
196
|
+
kind: "unknown",
|
|
197
|
+
summary: "The catalog does not have the cause.",
|
|
198
|
+
step: "Collect more facts, and report the facts as a new fault of the catalog.",
|
|
199
|
+
});
|
|
200
|
+
priors.set(UNKNOWN, spec.priors?.unknown ?? 1);
|
|
201
|
+
for (const [id, prior] of priors) if (!Number.isFinite(prior) || prior <= 0) problems.push(`The prior of ${id} is ${prior}. It must be more than 0.`);
|
|
202
|
+
if (problems.length > 0) return err(problems);
|
|
203
|
+
|
|
204
|
+
const total = [...priors.values()].reduce((a, b) => a + b, 0);
|
|
205
|
+
const reach = new Map(hypotheses.map((h) => [h.id, h.reach]));
|
|
206
|
+
return ok({
|
|
207
|
+
version: spec.version,
|
|
208
|
+
features,
|
|
209
|
+
hypotheses,
|
|
210
|
+
prior: (hypothesis) => (priors.get(hypothesis) ?? 0) / total,
|
|
211
|
+
likelihood(hypothesis, featureId, value, primary) {
|
|
212
|
+
const feature = features.get(featureId);
|
|
213
|
+
if (feature === undefined || !feature.values.includes(value)) return Number.NaN;
|
|
214
|
+
if (hypothesis === UNKNOWN) return 1 / feature.values.length;
|
|
215
|
+
const fault = tables.get(hypothesis);
|
|
216
|
+
const changed = fault?.get(featureId);
|
|
217
|
+
const applies = primary || reach.get(hypothesis) === "shared";
|
|
218
|
+
return (applies && changed !== undefined ? changed : feature.healthy)[value] ?? Number.NaN;
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* One observation of a feature.
|
|
225
|
+
*
|
|
226
|
+
* `primary` tells if the observation is of the primary target or of a reference host. `facts` gives the ids of the facts
|
|
227
|
+
* that gave the observation, for the explanation.
|
|
228
|
+
*/
|
|
229
|
+
export interface Evidence {
|
|
230
|
+
readonly feature: string;
|
|
231
|
+
readonly value: string;
|
|
232
|
+
readonly primary: boolean;
|
|
233
|
+
readonly facts?: readonly string[] | undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A cause with its probability. */
|
|
237
|
+
export interface Cause extends Hypothesis {
|
|
238
|
+
readonly probability: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The result of a diagnosis: the causes from the most probable, and the evidence that the model did not use, with the reason. */
|
|
242
|
+
export interface Diagnosis {
|
|
243
|
+
readonly causes: readonly Cause[];
|
|
244
|
+
readonly used: readonly Evidence[];
|
|
245
|
+
readonly ignored: readonly { readonly evidence: Evidence; readonly reason: string }[];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function usable(catalog: Catalog, evidence: readonly Evidence[]): Pick<Diagnosis, "used" | "ignored"> {
|
|
249
|
+
const used: Evidence[] = [];
|
|
250
|
+
const ignored: { evidence: Evidence; reason: string }[] = [];
|
|
251
|
+
for (const item of evidence) {
|
|
252
|
+
const feature = catalog.features.get(item.feature);
|
|
253
|
+
if (feature === undefined) ignored.push({ evidence: item, reason: `The catalog has no feature ${item.feature}.` });
|
|
254
|
+
else if (!feature.values.includes(item.value)) ignored.push({ evidence: item, reason: `"${item.value}" is not a value of ${item.feature}.` });
|
|
255
|
+
else used.push(item);
|
|
256
|
+
}
|
|
257
|
+
return { used, ignored };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* This function gives the probability of each hypothesis from the evidence, with the rule of Bayes.
|
|
262
|
+
*
|
|
263
|
+
* The model takes the observations as independent for a given hypothesis. The calculation uses logarithms, so a long
|
|
264
|
+
* list of evidence does not go to zero. The order of the evidence does not change the result.
|
|
265
|
+
* An exact tie keeps the order of the catalog, with "no fault" first.
|
|
266
|
+
*/
|
|
267
|
+
export function diagnose(catalog: Catalog, evidence: readonly Evidence[]): Diagnosis {
|
|
268
|
+
const { used, ignored } = usable(catalog, evidence);
|
|
269
|
+
const logs = catalog.hypotheses.map((h) => {
|
|
270
|
+
let sum = Math.log(catalog.prior(h.id));
|
|
271
|
+
for (const item of used) sum += Math.log(catalog.likelihood(h.id, item.feature, item.value, item.primary));
|
|
272
|
+
return sum;
|
|
273
|
+
});
|
|
274
|
+
const top = Math.max(...logs);
|
|
275
|
+
const weights = logs.map((log) => Math.exp(log - top));
|
|
276
|
+
const total = weights.reduce((a, b) => a + b, 0);
|
|
277
|
+
// An exact tie keeps the order of the catalog: "no fault" first, then the faults, then "unknown cause". Thus a fault
|
|
278
|
+
// whose features are not observed does not come before "no fault".
|
|
279
|
+
const causes = catalog.hypotheses
|
|
280
|
+
.map((h, i) => ({ ...h, probability: (weights[i] ?? 0) / total, order: i }))
|
|
281
|
+
.sort((a, b) => b.probability - a.probability || a.order - b.order)
|
|
282
|
+
.map(({ order: _order, ...cause }) => cause);
|
|
283
|
+
return { causes, used, ignored };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The part of one observation in the comparison of two hypotheses. */
|
|
287
|
+
export interface Contribution {
|
|
288
|
+
readonly evidence: Evidence;
|
|
289
|
+
/** The ratio of the likelihoods of the two hypotheses, as a base-10 logarithm. 1 is a factor of 10 for the first hypothesis. */
|
|
290
|
+
readonly weight: number;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* This function tells why the model prefers one hypothesis to a second hypothesis.
|
|
295
|
+
*
|
|
296
|
+
* It gives each observation with its weight, from the largest weight in absolute value. The default second hypothesis is
|
|
297
|
+
* "no fault".
|
|
298
|
+
*/
|
|
299
|
+
export function explain(catalog: Catalog, evidence: readonly Evidence[], hypothesis: string, versus: string = NONE): Contribution[] {
|
|
300
|
+
const { used } = usable(catalog, evidence);
|
|
301
|
+
return used
|
|
302
|
+
.map((item) => ({
|
|
303
|
+
evidence: item,
|
|
304
|
+
weight: Math.log10(catalog.likelihood(hypothesis, item.feature, item.value, item.primary)) -
|
|
305
|
+
Math.log10(catalog.likelihood(versus, item.feature, item.value, item.primary)),
|
|
306
|
+
}))
|
|
307
|
+
.sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* This function gives the probability of each value of a feature that is not observed yet, from the probabilities of
|
|
312
|
+
* the causes. The choice of the next probe uses it.
|
|
313
|
+
*/
|
|
314
|
+
export function predictive(catalog: Catalog, diagnosis: Diagnosis, feature: string, primary: boolean): Distribution {
|
|
315
|
+
const known = catalog.features.get(feature);
|
|
316
|
+
if (known === undefined) throw new RangeError(`The catalog has no feature ${feature}`);
|
|
317
|
+
const dist: Record<string, number> = {};
|
|
318
|
+
for (const value of known.values) {
|
|
319
|
+
dist[value] = diagnosis.causes.reduce((sum, cause) => sum + cause.probability * catalog.likelihood(cause.id, feature, value, primary), 0);
|
|
320
|
+
}
|
|
321
|
+
return dist;
|
|
322
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { err, ok, type Result } from "@mark1russell7/optional";
|
|
2
|
+
import { type ClassScore, type Confusion, type Proportion, brier, classScores, confusion, wilson } from "@mark1russell7/rigor-statistics";
|
|
3
|
+
import type { Catalog, Diagnosis } from "./model.ts";
|
|
4
|
+
|
|
5
|
+
/** The format of a lab record. */
|
|
6
|
+
export const LAB_RECORD_FORMAT = "netdiag.lab-record/1";
|
|
7
|
+
|
|
8
|
+
/** The probability that a diagnosis gives to one cause. */
|
|
9
|
+
export interface LabCause {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly probability: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One run of a lab case: the hypothesis that the lab made true, and the diagnosis of netdiag. */
|
|
15
|
+
export interface LabRecord {
|
|
16
|
+
readonly format: typeof LAB_RECORD_FORMAT;
|
|
17
|
+
/** The id of the case, for example `tls.cert-invalid/expired`. */
|
|
18
|
+
readonly case: string;
|
|
19
|
+
/** The version of the catalog that gave the diagnosis. */
|
|
20
|
+
readonly catalog: string;
|
|
21
|
+
/** The hypothesis that the lab made true: a fault of the catalog, `none` or `unknown`. */
|
|
22
|
+
readonly truth: string;
|
|
23
|
+
/** The most probable cause of the diagnosis. */
|
|
24
|
+
readonly predicted: string;
|
|
25
|
+
/** The rank of the truth in the causes, from 1. */
|
|
26
|
+
readonly rank: number;
|
|
27
|
+
/** The probability of each cause, from the most probable. */
|
|
28
|
+
readonly causes: readonly LabCause[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* This function makes the record of one run of a lab case. The truth must be a hypothesis of the catalog, and the
|
|
33
|
+
* diagnosis must have a cause.
|
|
34
|
+
*/
|
|
35
|
+
export function labRecordOf(catalog: Catalog, caseId: string, truth: string, diagnosis: Diagnosis): Result<LabRecord, string> {
|
|
36
|
+
if (!catalog.hypotheses.some((hypothesis) => hypothesis.id === truth)) return err(`The truth "${truth}" is not a hypothesis of the catalog ${catalog.version}.`);
|
|
37
|
+
const first = diagnosis.causes[0];
|
|
38
|
+
if (first === undefined) return err("The diagnosis has no cause.");
|
|
39
|
+
const index = diagnosis.causes.findIndex((cause) => cause.id === truth);
|
|
40
|
+
if (index < 0) return err(`The diagnosis has no cause "${truth}".`);
|
|
41
|
+
return ok({
|
|
42
|
+
format: LAB_RECORD_FORMAT,
|
|
43
|
+
case: caseId,
|
|
44
|
+
catalog: catalog.version,
|
|
45
|
+
truth,
|
|
46
|
+
predicted: first.id,
|
|
47
|
+
rank: index + 1,
|
|
48
|
+
causes: diagnosis.causes.map((cause) => ({ id: cause.id, probability: cause.probability })),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The scores of the lab records of one catalog. */
|
|
53
|
+
export interface LabScore {
|
|
54
|
+
/** The version of the catalog. */
|
|
55
|
+
readonly catalog: string;
|
|
56
|
+
/** The number of records. */
|
|
57
|
+
readonly n: number;
|
|
58
|
+
/** The number of records where the predicted cause is the truth. */
|
|
59
|
+
readonly correct: number;
|
|
60
|
+
/** The Wilson interval of the part of the records where the predicted cause is the truth. */
|
|
61
|
+
readonly accuracy: Proportion;
|
|
62
|
+
/** The confusion table. It has a row for each hypothesis of the catalog, in the order of the catalog. */
|
|
63
|
+
readonly table: Confusion<string>;
|
|
64
|
+
/** The scores of each hypothesis, in the order of the catalog. */
|
|
65
|
+
readonly classes: readonly ClassScore<string>[];
|
|
66
|
+
/** The mean Brier score of the records: the squared distance of the probabilities from the truth, from 0 to 2. */
|
|
67
|
+
readonly brier: number | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* This function scores the lab records of one catalog. The recall of a hypothesis is the part of its runs where the
|
|
72
|
+
* diagnosis names it. A record of a different catalog version, or with a hypothesis that is not in the catalog, is an error.
|
|
73
|
+
*/
|
|
74
|
+
export function scoreLab(catalog: Catalog, records: readonly LabRecord[]): Result<LabScore, string[]> {
|
|
75
|
+
const ids = catalog.hypotheses.map((hypothesis) => hypothesis.id);
|
|
76
|
+
const known = new Set(ids);
|
|
77
|
+
const problems: string[] = [];
|
|
78
|
+
records.forEach((record, i) => {
|
|
79
|
+
const name = `Record ${i} (${record.case})`;
|
|
80
|
+
if (record.format !== LAB_RECORD_FORMAT) problems.push(`${name} has the format "${String(record.format)}", not "${LAB_RECORD_FORMAT}".`);
|
|
81
|
+
if (record.catalog !== catalog.version) problems.push(`${name} comes from the catalog ${record.catalog}, not ${catalog.version}.`);
|
|
82
|
+
for (const id of new Set([record.truth, record.predicted, ...record.causes.map((cause) => cause.id)])) {
|
|
83
|
+
if (!known.has(id)) problems.push(`${name} names "${id}", which is not a hypothesis of the catalog.`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
if (problems.length > 0) return err(problems);
|
|
87
|
+
const table = confusion(
|
|
88
|
+
records.map((record) => record.truth),
|
|
89
|
+
records.map((record) => record.predicted),
|
|
90
|
+
ids,
|
|
91
|
+
);
|
|
92
|
+
const forecasts = records.flatMap((record) => {
|
|
93
|
+
const probability = new Map(record.causes.map((cause) => [cause.id, cause.probability]));
|
|
94
|
+
return ids.map((id) => ({ p: probability.get(id) ?? 0, hit: id === record.truth }));
|
|
95
|
+
});
|
|
96
|
+
return ok({
|
|
97
|
+
catalog: catalog.version,
|
|
98
|
+
n: table.n,
|
|
99
|
+
correct: table.correct,
|
|
100
|
+
accuracy: wilson(table.correct, table.n),
|
|
101
|
+
table,
|
|
102
|
+
classes: classScores(table),
|
|
103
|
+
brier: records.length === 0 ? undefined : brier(forecasts) * ids.length,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The least lower end of the Wilson interval of the recall, for each hypothesis that the gate holds. */
|
|
108
|
+
export type LabBars = Readonly<Record<string, number>>;
|
|
109
|
+
|
|
110
|
+
/** The verdict of the gate on one hypothesis: `no-read` when no record has it as the truth. */
|
|
111
|
+
export type LabStatus = "pass" | "fail" | "no-read";
|
|
112
|
+
|
|
113
|
+
/** The verdict of the gate on one hypothesis. */
|
|
114
|
+
export interface LabVerdict {
|
|
115
|
+
readonly hypothesis: string;
|
|
116
|
+
readonly status: LabStatus;
|
|
117
|
+
/** The bar of the hypothesis. */
|
|
118
|
+
readonly bar: number;
|
|
119
|
+
/** The lower end of the recall interval. It is `undefined` when no record has the hypothesis as the truth. */
|
|
120
|
+
readonly low: number | undefined;
|
|
121
|
+
/** The number of records with the hypothesis as the truth. */
|
|
122
|
+
readonly support: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The result of the accuracy gate. */
|
|
126
|
+
export interface LabGate {
|
|
127
|
+
/** It is `true` only when each verdict is `pass`. A hypothesis with no read does not pass. */
|
|
128
|
+
readonly pass: boolean;
|
|
129
|
+
/** The verdicts, in the order of the catalog. */
|
|
130
|
+
readonly verdicts: readonly LabVerdict[];
|
|
131
|
+
/** The hypotheses with no bar, for example a fault that no lab can make. */
|
|
132
|
+
readonly unbarred: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* This function holds the recall of each hypothesis with a bar. A hypothesis passes when the lower end of its
|
|
137
|
+
* recall interval is the bar or more. A bar for a hypothesis that is not in the score is an error.
|
|
138
|
+
*/
|
|
139
|
+
export function labGate(score: LabScore, bars: LabBars): Result<LabGate, string[]> {
|
|
140
|
+
const problems: string[] = [];
|
|
141
|
+
const labels = new Set(score.table.labels);
|
|
142
|
+
for (const [hypothesis, bar] of Object.entries(bars)) {
|
|
143
|
+
if (!labels.has(hypothesis)) problems.push(`The bar of "${hypothesis}" is not for a hypothesis of the catalog ${score.catalog}.`);
|
|
144
|
+
if (!(bar >= 0 && bar <= 1)) problems.push(`The bar of "${hypothesis}" is ${bar}. A bar is from 0 to 1.`);
|
|
145
|
+
}
|
|
146
|
+
if (problems.length > 0) return err(problems);
|
|
147
|
+
const verdicts: LabVerdict[] = [];
|
|
148
|
+
const unbarred: string[] = [];
|
|
149
|
+
for (const scores of score.classes) {
|
|
150
|
+
const bar = bars[scores.label];
|
|
151
|
+
if (bar === undefined) {
|
|
152
|
+
unbarred.push(scores.label);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const low = scores.support === 0 ? undefined : scores.recallInterval.low;
|
|
156
|
+
const status: LabStatus = low === undefined ? "no-read" : low >= bar ? "pass" : "fail";
|
|
157
|
+
verdicts.push({ hypothesis: scores.label, status, bar, low, support: scores.support });
|
|
158
|
+
}
|
|
159
|
+
return ok({ pass: verdicts.length > 0 && verdicts.every((verdict) => verdict.status === "pass"), verdicts, unbarred });
|
|
160
|
+
}
|
package/src/fact.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Layer } from "./layer.ts";
|
|
2
|
+
import type { Target } from "./target.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The time of a fact, in milliseconds on the monotonic clock of the runner.
|
|
6
|
+
*
|
|
7
|
+
* The field `origin` of the runner gives the wall-clock time of one monotonic time. Thus each fact time has a wall-clock time.
|
|
8
|
+
*/
|
|
9
|
+
export interface FactTime {
|
|
10
|
+
readonly start: number;
|
|
11
|
+
readonly end: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The source of a fact: the probe and its version, and the outside tool when the probe starts one. */
|
|
15
|
+
export interface FactSource {
|
|
16
|
+
readonly probe: string;
|
|
17
|
+
readonly version: string;
|
|
18
|
+
readonly tool?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A typed fact of one layer of the network stack.
|
|
23
|
+
*
|
|
24
|
+
* A fact has a layer, a time, a source and a value. Its kind names the type of its value, for example `tcp.connect`.
|
|
25
|
+
* The subject is the target of the fact. A fact is plain data, so a record of facts is JSON.
|
|
26
|
+
*/
|
|
27
|
+
export interface Fact<V = unknown> {
|
|
28
|
+
/** The id is unique in the facts of one runner, for example `f12`. */
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly kind: string;
|
|
31
|
+
readonly layer: Layer;
|
|
32
|
+
readonly subject: Target;
|
|
33
|
+
readonly time: FactTime;
|
|
34
|
+
readonly source: FactSource;
|
|
35
|
+
readonly value: V;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The definition of a kind of fact: its name, its layer and the check of its value.
|
|
40
|
+
*
|
|
41
|
+
* The check is the contract of the value. The runner refuses a value that does not pass it, thus each fact of a kind has a
|
|
42
|
+
* value of the type `V`.
|
|
43
|
+
*/
|
|
44
|
+
export interface FactKind<V> {
|
|
45
|
+
readonly kind: string;
|
|
46
|
+
readonly layer: Layer;
|
|
47
|
+
readonly summary: string;
|
|
48
|
+
readonly check: (value: unknown) => value is V;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The value type of a fact kind. */
|
|
52
|
+
export type ValueOf<K> = K extends FactKind<infer V> ? V : never;
|
|
53
|
+
|
|
54
|
+
const KIND_NAME = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
|
55
|
+
|
|
56
|
+
/** This function makes a fact kind. The name has lower-case words with dots, for example `tls.handshake`. */
|
|
57
|
+
export function defineFactKind<V>(spec: FactKind<V>): FactKind<V> {
|
|
58
|
+
if (!KIND_NAME.test(spec.kind)) throw new RangeError(`"${spec.kind}" is not a valid name of a fact kind`);
|
|
59
|
+
return Object.freeze({ ...spec });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** This function tells if a fact is of a kind, and if its value passes the check of the kind. */
|
|
63
|
+
export function isFactOf<V>(kind: FactKind<V>, fact: Fact): fact is Fact<V> {
|
|
64
|
+
return fact.kind === kind.kind && fact.layer === kind.layer && kind.check(fact.value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** This function gives the facts of one kind, in their order. */
|
|
68
|
+
export function factsOf<V>(kind: FactKind<V>, facts: readonly Fact[]): Fact<V>[] {
|
|
69
|
+
return facts.filter((fact): fact is Fact<V> => isFactOf(kind, fact));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An observation is a fact that a probe parsed, before the runner gives it an id, a layer and a source.
|
|
74
|
+
*
|
|
75
|
+
* The time is optional. Without a time, the fact gets the time of the run of the probe.
|
|
76
|
+
*/
|
|
77
|
+
export interface Observation<V = unknown> {
|
|
78
|
+
readonly kind: FactKind<V>;
|
|
79
|
+
readonly subject: Target;
|
|
80
|
+
readonly value: V;
|
|
81
|
+
readonly time?: FactTime | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** This function makes an observation with a type check of its value against its kind. */
|
|
85
|
+
export function observe<V>(kind: FactKind<V>, subject: Target, value: V, time?: FactTime): Observation<V> {
|
|
86
|
+
return time === undefined ? { kind, subject, value } : { kind, subject, value, time };
|
|
87
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export { LAYERS, isLayer, layerRank, type Layer } from "./layer.ts";
|
|
2
|
+
export { canonicalHost, formatTarget, hostKind, parseTarget, sameTarget, target, type HostKind, type Target } from "./target.ts";
|
|
3
|
+
export {
|
|
4
|
+
defineFactKind,
|
|
5
|
+
factsOf,
|
|
6
|
+
isFactOf,
|
|
7
|
+
observe,
|
|
8
|
+
type Fact,
|
|
9
|
+
type FactKind,
|
|
10
|
+
type FactSource,
|
|
11
|
+
type FactTime,
|
|
12
|
+
type Observation,
|
|
13
|
+
type ValueOf,
|
|
14
|
+
} from "./fact.ts";
|
|
15
|
+
export { defineProbe, type Availability, type Probe, type ProbeContext, type ProbeMode } from "./probe.ts";
|
|
16
|
+
export {
|
|
17
|
+
DEFAULT_MAX_PORTS_PER_HOST,
|
|
18
|
+
decideScope,
|
|
19
|
+
grantProblems,
|
|
20
|
+
namedGrant,
|
|
21
|
+
resolvedGrant,
|
|
22
|
+
scopeOf,
|
|
23
|
+
widen,
|
|
24
|
+
type Grant,
|
|
25
|
+
type GrantBasis,
|
|
26
|
+
type HostGrant,
|
|
27
|
+
type PortLedger,
|
|
28
|
+
type RangeGrant,
|
|
29
|
+
type Scope,
|
|
30
|
+
type ScopeDecision,
|
|
31
|
+
} from "./scope.ts";
|
|
32
|
+
export { createRunner, type ClockOrigin, type ProbeOutcome, type Runner, type RunnerOptions } from "./run.ts";
|
|
33
|
+
export * from "./address/index.ts";
|
|
34
|
+
export * from "./diagnosis/index.ts";
|
|
35
|
+
export * from "./kinds.ts";
|
|
36
|
+
export {
|
|
37
|
+
DEFAULT_MAX_BYTES,
|
|
38
|
+
DNS_PROBE,
|
|
39
|
+
HTTP_PROBE,
|
|
40
|
+
TCP_PROBE,
|
|
41
|
+
TLS_PROBE,
|
|
42
|
+
dnsOutcome,
|
|
43
|
+
httpProbe,
|
|
44
|
+
tcpProbe,
|
|
45
|
+
tlsProbe,
|
|
46
|
+
type DnsInput,
|
|
47
|
+
type DnsRaw,
|
|
48
|
+
type HttpInput,
|
|
49
|
+
type HttpRaw,
|
|
50
|
+
type TcpInput,
|
|
51
|
+
type TcpRaw,
|
|
52
|
+
type TlsInput,
|
|
53
|
+
type TlsProbeOptions,
|
|
54
|
+
type TlsRaw,
|
|
55
|
+
} from "./probes/native.ts";
|
|
56
|
+
export {
|
|
57
|
+
DEFAULT_TIMEOUT_MS,
|
|
58
|
+
KEPT_FIELDS,
|
|
59
|
+
certProblem,
|
|
60
|
+
connectOutcome,
|
|
61
|
+
handshakeOutcome,
|
|
62
|
+
type ConnectOptions,
|
|
63
|
+
type Dial,
|
|
64
|
+
type HttpPhase,
|
|
65
|
+
type TcpPhase,
|
|
66
|
+
type TlsPhase,
|
|
67
|
+
} from "./probes/socket.ts";
|
|
68
|
+
export * from "./readers.ts";
|