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
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,27 @@ thing this tool has that mainstream speed tests do not.

---

## The answer layer (`src/analysis/`)

The rules engine turns measurements into findings. It is the one place where the project draws
conclusions rather than reporting numbers, so it has its own version of the rule above:

> **No measurement, no finding.** A rule whose declared inputs are absent returns `null`. It is
> skipped and the gap is reported — it never evaluates against a substitute.

- Keep rules **pure and total**: `(snapshot) => RuleHit | null`, no shared state, no ordering
dependency, no clock, no network. `evaluateRules` on an empty snapshot must return `[]`, and there
is a test that says so. That test is the engine's equivalent of the offline regression run.
- **Confidence is ordinal, never a percentage.** `confirmed` / `likely` / `possible` each have a
stated meaning. "83% confident" would be an invented number with no calculation behind it.
- **"Checked and found nothing" ≠ "did not check".** `no-fault-found` requires
`MIN_CHECKS_FOR_ALL_CLEAR` conclusive checks; below that the verdict is `indeterminate`, which
declines in both directions. Silence must never read as a clean bill of health.
- Every threshold lives in `THRESHOLDS` with a comment justifying it. They are judgements about
human experience, not measurements, and they change what the tool tells people.
- `attributeBottleneck` does substitute values — into a hypothetical re-score, never into a report.
A test asserts no reference value can reach the output. Keep it that way.

## Conventions

- Comments explain *why*, especially where the non-obvious choice is deliberate. Several
Expand Down
78 changes: 75 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,74 @@ why it was hard to spot; [`CLAUDE.md`](CLAUDE.md) holds the rules that keep it f

---

## 🧠 The answer layer

Measurements are the easy half. The question people actually arrive with is **"is it me or the
internet?"**, and a grade does not answer it.

### 🩺 One-button triage

Walks a real decision tree — link → name resolution → interception → address families → four
unrelated content networks → throughput → behaviour under load — and returns a **verdict with
ranked probable causes and concrete fixes**, not a letter.

The reasoning is a data-driven rules engine in [`src/analysis/`](src/analysis/). Each rule declares
the metrics it consumes, a predicate, a verdict, an ordinal confidence and a remediation, and every
finding carries the measurements that produced it. That makes it **deterministic, instant, offline,
and auditable** — same evidence in, same answer out, with no model involved and no network request
needed to reason. `@google/genai` was removed from this project deliberately; nothing here calls an
API to think.

Two rules govern it, and both are tested:

- **A rule whose inputs were not measured does not fire.** It is skipped, and the gap is reported.
- **Silence is not a clean bill of health.** Fewer than three conclusive checks yields
`indeterminate` — explicitly *not* "your network is fine". A run that measured nothing concludes
nothing.

### 🎯 Bottleneck attribution

The dashboard headline now names the *binding constraint* rather than showing four flat bars:

> **Grade B — the binding constraint is bufferbloat, not bandwidth.** Latency rose from 18 ms idle
> to 80 ms under load, an increase of 62 ms.

It is computed by sensitivity analysis over the existing scoring function: each measured input is
lifted, one at a time, to a level past which it stops limiting the result, and whichever lift moves
the score furthest is the constraint. Those comparison values live inside the calculation and are
never reported as measurements — a test asserts they cannot leak into the output. Severe bufferbloat
overrides the ranking, because the numeric score does not model it and the user's experience does.

### 🌍 Dual-stack (IPv4 / IPv6) reachability

Calls hostnames that publish **only** an A record and hostnames that publish **only** an AAAA
record, two independent providers per family, then asks a dual-stack host which address it saw —
which reveals the family the browser actually prefers.

No IPv6 response is reported as *"no response"*, never as *"IPv6 is disabled"*: a browser cannot
distinguish an absent IPv6 path from two probe hosts being unreachable, and a family that was never
probed reads as `not checked` rather than as a failure — including in the CSV export.

### 🛰️ Captive portal & DNS hijack detection

The textbook `generate_204` redirect check needs a plaintext request, and a page served over HTTPS
may not make one. That limitation is reported (`mixed-content-blocked`) rather than worked around,
and the probe *does* run when NetReady is opened from a local `http` origin.

Over HTTPS the signature is different, and that is the useful insight: **a captive portal cannot
rewrite an HTTPS response without breaking the certificate chain, so it blocks instead.** So this
checks endpoints whose exact response is known in advance and reports which returned their own
content, which returned something else (interception with a trusted certificate), and which said
nothing at all while the browser still claimed to be online (the portal signature).

For DNS, it does the one test of the *system* resolver a web page can perform: reach one server two
ways — by name, which uses the resolver, and by literal IP, which does not. Literal answering while
the name does not is a broken or redirected resolver. Two DoH providers are also cross-checked, but
only on anycast names whose correct answer is identical worldwide; ordinary CDN hostnames disagree
by design and would manufacture findings out of geography.

---

## 🛠️ Tools

### 1. ⚡ Speed & Bandwidth
Expand Down Expand Up @@ -133,8 +201,11 @@ without touching it, so the following go directly from your browser to third par
| `cdn.jsdelivr.net`, `cdnjs.cloudflare.com`, `unpkg.com` | Your IP, as Edge Path Explorer probe targets (a few KB each) |
| `cloudflare-dns.com`, `dns.google` | Every domain you resolve, over encrypted DoH |
| `ipwho.is`, `ipapi.co`, `freeipapi.com` | Your public IP on opening the GeoIP tool, and every IP or domain you look up |
| `1.1.1.1`, `dns.quad9.net`, `doh.opendns.com`, `en.wikipedia.org` | Your IP, as latency probe targets |
| `1.1.1.1`, `one.one.one.one`, `dns.quad9.net`, `doh.opendns.com`, `en.wikipedia.org` | Your IP, as latency probe targets, and as the two halves of the resolver test |
| `ipv4.icanhazip.com`, `ipv6.icanhazip.com`, `api4.ipify.org`, `api6.ipify.org` | Your IP, during the dual-stack check — each answers on one address family only |
| `cp.cloudflare.com` | Your IP, during the captive-portal check, and only when NetReady is opened over plain `http` |
| `stun.l.google.com` and other STUN servers | Your public IP, and potentially local addresses |
| `httpbin.org` | Your IP, only when you press "Trigger Network Spike" on the live traffic monitor |
| `basemaps.cartocdn.com`, `openstreetmap.org` | Map areas you view, revealing an approximate target location |
| Hosts you enter | Direct connections from your browser — that is what a probe *is* |

Expand All @@ -150,8 +221,9 @@ devices and hosts you own or have explicit permission to test.
- `npm run typecheck` — TypeScript in `strict` mode, zero errors.
- `npm run lint` — ESLint with `react-hooks`, zero errors.
- `npm run test` — Vitest. Coverage focuses on the pure logic where silent failures hide: CSV
generation, CIDR math, OUI decoding, and the rule that a failed measurement can never produce a
number.
generation, CIDR math, OUI decoding, the rules engine and bottleneck attribution, and the rule
that a failed measurement can never produce a number. The single most important assertion in the
suite is that an empty snapshot fires no rule at all.

CI runs all three on every push and pull request; deployment is gated on them passing.

Expand Down
9 changes: 9 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import { getHistory, getLocalStorageSizeBytes } from './utils/storage';
import { Navbar } from './components/Navbar';
import { Dashboard } from './components/Dashboard';
import { TriagePanel } from './components/TriagePanel';
import { DualStackCheck } from './components/DualStackCheck';
import { CaptivePortalCheck } from './components/CaptivePortalCheck';
import { EdgePathExplorer } from './components/EdgePathExplorer';
import { TracertVisualizer } from './components/TracertVisualizer';
import { PortScanner } from './components/PortScanner';
Expand Down Expand Up @@ -41,7 +44,7 @@
window.addEventListener('online', handleStatusChange);
window.addEventListener('offline', handleStatusChange);

const connection = (navigator as any).connection;

Check warning on line 47 in src/App.tsx

View workflow job for this annotation

GitHub Actions / check

Unexpected any. Specify a different type
if (connection) {
connection.addEventListener('change', handleStatusChange);
}
Expand Down Expand Up @@ -80,6 +83,12 @@
/>
)}

{activeTab === 'triage' && <TriagePanel onHistoryUpdate={refreshHistory} />}

{activeTab === 'dualstack' && <DualStackCheck onHistoryUpdate={refreshHistory} />}

{activeTab === 'captive' && <CaptivePortalCheck onHistoryUpdate={refreshHistory} />}

{activeTab === 'edgepath' && (
<EdgePathExplorer onHistoryUpdate={refreshHistory} />
)}
Expand Down
168 changes: 168 additions & 0 deletions src/analysis/bottleneck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest';
import { attributeBottleneck, constraintLabel } from './bottleneck';
import { calculateNetReadyScore } from '../utils/network';
import type { PingResult, SpeedTestResult } from '../types';

const speed = (over: Partial<SpeedTestResult> = {}): SpeedTestResult => ({
id: 's',
timestamp: 0,
downloadSpeed: null,
uploadSpeed: null,
ping: null,
jitter: null,
loadedPing: null,
bufferbloatScore: null,
...over,
});

const ping = (over: Partial<PingResult> = {}): PingResult => ({
id: 'p',
timestamp: 0,
target: 't',
label: 'l',
packetsSent: 10,
packetsReceived: 10,
packetLoss: 0,
minPing: null,
maxPing: null,
avgPing: null,
jitter: null,
points: [],
...over,
});

const attribute = (s: SpeedTestResult | null, p: PingResult | null) =>
attributeBottleneck(s, p, calculateNetReadyScore(s, p));

describe('attributeBottleneck', () => {
it('says nothing when nothing has been measured', () => {
const r = attribute(null, null);
expect(r.constraint).toBeNull();
expect(r.unavailableReason).toMatch(/Nothing has been measured/);
expect(r.sensitivities).toEqual([]);
});

it('names bandwidth when bandwidth is what is holding the score down', () => {
const r = attribute(
speed({ downloadSpeed: 3, uploadSpeed: 30, ping: 12, jitter: 1 }),
ping({ avgPing: 12, jitter: 1 }),
);
expect(r.constraint).toBe('download');
expect(r.headline).toContain('download bandwidth');
expect(r.evidence[0].observation).toContain('3 Mbps');
});

it('names latency when the link is fast but far', () => {
const r = attribute(
speed({ downloadSpeed: 500, uploadSpeed: 100, ping: 320, jitter: 2 }),
ping({ avgPing: 320, jitter: 2 }),
);
expect(r.constraint).toBe('latency');
});

it('names jitter when only jitter is bad', () => {
const r = attribute(
speed({ downloadSpeed: 500, uploadSpeed: 100, ping: 12, jitter: 60 }),
ping({ avgPing: 12, jitter: 60 }),
);
expect(r.constraint).toBe('jitter');
});

it('promotes bufferbloat over bandwidth, and says why', () => {
// The headline case from the brief: a decent-looking link whose real
// problem is queuing, which the numeric score does not model at all.
const r = attribute(
speed({ downloadSpeed: 45, uploadSpeed: 12, ping: 18, jitter: 3, loadedPing: 80 }),
ping({ avgPing: 18, jitter: 3 }),
);
expect(r.constraint).toBe('bufferbloat');
expect(r.headline).toMatch(/binding constraint is bufferbloat, not bandwidth/);
// The grade and the experience diverge here — the headline has to say so,
// or "Grade A+" and "something is constraining you" read as a contradiction.
expect(r.headline).toMatch(/on the numbers/);
expect(r.detail).toContain('62 ms');
expect(r.evidence.map((e) => e.metric)).toEqual(['speed.ping', 'speed.loadedPing']);
});

it('leaves bufferbloat out of it when the increase is small', () => {
const r = attribute(
speed({ downloadSpeed: 4, uploadSpeed: 12, ping: 18, jitter: 3, loadedPing: 30 }),
ping({ avgPing: 18, jitter: 3 }),
);
expect(r.constraint).toBe('download');
});

it('will not invent a bufferbloat verdict from a single sample', () => {
const r = attribute(
speed({ downloadSpeed: 4, uploadSpeed: 12, ping: 18, jitter: 3, loadedPing: null }),
ping({ avgPing: 18, jitter: 3 }),
);
expect(r.constraint).toBe('download');
});

it('names no constraint when everything measured is already good', () => {
const r = attribute(
speed({ downloadSpeed: 900, uploadSpeed: 900, ping: 5, jitter: 1, loadedPing: 6 }),
ping({ avgPing: 5, jitter: 1 }),
);
expect(r.constraint).toBeNull();
expect(r.headline).toMatch(/nothing measured is holding this back/);
expect(r.unavailableReason).toBeTruthy();
});

it('ranks only the inputs that were actually measured', () => {
// Download alone. Upload, latency and jitter must not appear as though
// they had been weighed.
const r = attribute(speed({ downloadSpeed: 5, ping: 20, jitter: 2 }), null);
const inputs = r.sensitivities.map((s) => s.input);
expect(inputs).toContain('download');
expect(inputs).not.toContain('upload');
});

it('treats a measured zero as measured', () => {
// `??` rather than `||`: a genuine 0 Mbps must be ranked, not discarded.
const r = attribute(
speed({ downloadSpeed: 0, uploadSpeed: 20, ping: 20, jitter: 2 }),
ping({ avgPing: 20, jitter: 2 }),
);
expect(r.constraint).toBe('download');
expect(r.evidence[0].observation).toContain('0 Mbps');
});

it('produces sensitivities sorted strongest first', () => {
const r = attribute(
speed({ downloadSpeed: 2, uploadSpeed: 30, ping: 200, jitter: 40 }),
ping({ avgPing: 200, jitter: 40 }),
);
const gains = r.sensitivities.map((s) => s.gain);
expect([...gains].sort((a, b) => b - a)).toEqual(gains);
});

it('is deterministic', () => {
const s = speed({ downloadSpeed: 20, uploadSpeed: 5, ping: 40, jitter: 8, loadedPing: 55 });
const p = ping({ avgPing: 40, jitter: 8 });
expect(JSON.stringify(attribute(s, p))).toBe(JSON.stringify(attribute(s, p)));
});

it('never leaks a reference value into the output', () => {
// The "what if" values used inside the sensitivity calculation must not
// escape as though they had been measured.
const r = attribute(
speed({ downloadSpeed: 7, uploadSpeed: 3, ping: 90, jitter: 12 }),
ping({ avgPing: 90, jitter: 12 }),
);
const text = `${r.headline} ${r.detail} ${r.evidence.map((e) => e.observation).join(' ')}`;
expect(text).not.toContain('200 Mbps');
expect(text).not.toContain('40 Mbps');
expect(text).not.toContain('15 ms');
expect(text).toContain('7 Mbps');
});
});

describe('constraintLabel', () => {
it('names every constraint in plain language', () => {
expect(constraintLabel('bufferbloat')).toBe('bufferbloat');
expect(constraintLabel('download')).toBe('download bandwidth');
expect(constraintLabel('latency')).toBe('latency');
});
});
Loading
Loading