Skip to content
Closed
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
15 changes: 14 additions & 1 deletion docs/experiments/history.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generated": "2026-06-09T15:34:59.695047",
"generated": "2026-06-10T07:21:22.379316",
"runs": [
{
"id": "baseline-before-authorizer-hooks",
Expand Down Expand Up @@ -19301,6 +19301,19 @@
"timestamp": "2026-06-08T07:16:55",
"source": "nearby"
}
},
{
"id": "159",
"title": "`Row.containsKey` identity fast path",
"date": "2026-06-10",
"status": "rejected",
"summary": "Routing `Row.containsKey` through `RowSchema.indexOf` to extend exp 158's identity scan landed nominally at -3.4% on the `row_map_facade` `containsKey` row median (15.231 → 14.707 ms), but the candidate range (12.377 – 15.101 ms) fully overlapped the baseline range (13.930 – 15.329 ms). `HashMap<String, int>.containsKey` is already at the noise floor on canonical-string keys because Dart caches `String.hashCode`. The change is behaviour-preserving and exp 158's `indexOf` fast path stands; reopen only if a workload makes `containsKey` on canonical-and-present keys a material wall-time fraction.",
"commit": null,
"problem": "Experiment 158 added the schema-name identity fast path to `RowSchema.indexOf`\nand observed a clean win on full row consumption — `row_map_facade` hot lookup\ndropped 10.750 → 5.136 ms (-52%) and `select_maps` 10K main-isolate consumption\ndropped 1.998 → 0.967 ms (-52%).\n\nThat experiment intentionally left `Row.containsKey` on the original direct\n`_indexByName.containsKey(key)` path. The `containsKey` row median in exp 158\nmoved from 17.720 → 17.701 ms, recorded as neutral.\n\nThe bounded question for this run was therefore: does extending exp 158's\nidentity fast path to `Row.containsKey` produce a measurable win on the\nsame workload, or is the existing direct `HashMap.containsKey` already at\nthe noise floor of the available benchmark?",
"hypothesis": "`Row.containsKey` currently bypasses `RowSchema.indexOf` and goes straight to\n`_indexByName.containsKey(key)`. Routing it through `indexOf` would let\ncanonical-string lookups (the common case in user code that mirrors row.keys)\nshort-circuit inside the up-to-32-column identity scan that exp 158 already\nestablished, without changing any public API and without affecting\n`Row.operator[]`, which already uses `indexOf`.\n\nPredicted ceiling: roughly the same shape as exp 158's hot-lookup delta, since\nboth paths now share the same `indexOf` call.\n\nAccept only if `row_map_facade` containsKey medians drop materially relative to\nbaseline noise. Reject if the candidate collapses to the noise floor — a wide\nidentity scan plus HashMap fallback for non-identical keys is a real fallthrough\ncost, and ma...",
"approach": "`Row.containsKey` now calls `_schema.indexOf(key) >= 0` instead of\n`_schema._indexByName.containsKey(key)`:\n\n```dart\n@override\nbool containsKey(Object? key) =>\n key is String && _schema.indexOf(key) >= 0;\n```\n\n`indexOf` was unchanged. For schemas with `≤ 32` columns it still runs the\nidentity scan first, falling back to the private `HashMap<String, int>` for\nnon-identical or unknown keys. Schemas wider than 32 columns skip the identity\nloop entirely and go straight to the HashMap, identical to exp 158's behavior.\n\nNo other code, public API, or transfer surface was touched.\n\nA pre-run sanity check confirmed canonical-string identity holds in the\nbenchmark setup: `identical(\"updated_at\", schema.names[5])` returned `true` and\n`indexOf(\"updated_at\")` returned `5`. So the identity fast pa...",
"results": "Three paired runs of `dart run benchmark/experiments/row_map_facade.dart`,\nstashing the change for baseline and unstashing for candidate. `containsKey`\nrow medians (8-column schema, 500,000 inner iterations per measurement):\n\n| Run | Baseline row (ms) | Candidate row (ms) |\n|---|---:|---:|\n| 1 | 15.329 | 12.377 |\n| 2 | 15.231 | 14.707 |\n| 3 | 13.930 | 15.101 |\n\nRun-median summary:\n\n| Metric | Value |\n|---|---:|\n| Baseline median | 15.231 ms |\n| Candidate median | 14.707 ms |\n| Delta | -3.4% |\n| Baseline run-to-run range | 13.930 – 15.329 ms (1.4 ms span) |\n| Candidate run-to-run range | 12.377 – 15.101 ms (2.7 ms span) |\n\nThe candidate range fully overlaps the baseline range, and the run-3 candidate\nmedian (15.101 ms) is *above* the run-3 baseline median (13.930 ms). The\nnominal -3.4% d...",
"reasoning": "Reject.\n\nThe identity fast path is consistent with exp 158 and the change is\nbehavior-preserving, but the measured win on the available benchmark is below\nthe per-run noise floor. The most likely explanation is that\n`HashMap<String, int>.containsKey` is already very fast on canonical-string\nkeys — Dart caches `String.hashCode` on canonical strings, so the bucket lookup\ncollapses to a single hash + identity-compare. The candidate path replaces that\nwith a short identity-scan loop that performs almost the same comparison count.\n\nThere is also a small downside outside the canonical case: any\n`row.containsKey(nonCanonicalKey)` call now pays an up-to-32 element identity\nscan before falling through to the HashMap. For typical user code that passes\nliteral column names this is invisible, but a..."
}
],
"tracked": [
Expand Down
155 changes: 155 additions & 0 deletions experiments/159-row-containskey-identity-fast-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Experiment 159: Row.containsKey identity fast path

**Date:** 2026-06-10
**Status:** Rejected
**Direction:** `result-transfer-shape`
**Benchmark Run:** Focused `row_map_facade` paired A/B
**Archive:** Not created; the candidate is a one-line private method swap (see
snippet below) and there is no surrounding scaffolding worth preserving. The
row.dart change was reverted before merge consistent with rejection.

## Problem

Experiment 158 added the schema-name identity fast path to `RowSchema.indexOf`
and observed a clean win on full row consumption — `row_map_facade` hot lookup
dropped 10.750 → 5.136 ms (-52%) and `select_maps` 10K main-isolate consumption
dropped 1.998 → 0.967 ms (-52%).

That experiment intentionally left `Row.containsKey` on the original direct
`_indexByName.containsKey(key)` path. The `containsKey` row median in exp 158
moved from 17.720 → 17.701 ms, recorded as neutral.

The bounded question for this run was therefore: does extending exp 158's
identity fast path to `Row.containsKey` produce a measurable win on the
same workload, or is the existing direct `HashMap.containsKey` already at
the noise floor of the available benchmark?

## Hypothesis

`Row.containsKey` currently bypasses `RowSchema.indexOf` and goes straight to
`_indexByName.containsKey(key)`. Routing it through `indexOf` would let
canonical-string lookups (the common case in user code that mirrors row.keys)
short-circuit inside the up-to-32-column identity scan that exp 158 already
established, without changing any public API and without affecting
`Row.operator[]`, which already uses `indexOf`.

Predicted ceiling: roughly the same shape as exp 158's hot-lookup delta, since
both paths now share the same `indexOf` call.

Accept only if `row_map_facade` containsKey medians drop materially relative to
baseline noise. Reject if the candidate collapses to the noise floor — a wide
identity scan plus HashMap fallback for non-identical keys is a real fallthrough
cost, and matching `HashMap.containsKey` on canonical strings alone is not a
merge-worthy outcome.

## Approach

`Row.containsKey` now calls `_schema.indexOf(key) >= 0` instead of
`_schema._indexByName.containsKey(key)`:

```dart
@override
bool containsKey(Object? key) =>
key is String && _schema.indexOf(key) >= 0;
```

`indexOf` was unchanged. For schemas with `≤ 32` columns it still runs the
identity scan first, falling back to the private `HashMap<String, int>` for
non-identical or unknown keys. Schemas wider than 32 columns skip the identity
loop entirely and go straight to the HashMap, identical to exp 158's behavior.

No other code, public API, or transfer surface was touched.

A pre-run sanity check confirmed canonical-string identity holds in the
benchmark setup: `identical("updated_at", schema.names[5])` returned `true` and
`indexOf("updated_at")` returned `5`. So the identity fast path *does* fire on
the row_map_facade workload — the experiment is measuring the speedup of
identity-scan vs HashMap on canonical strings, not the cost of an unmatched
fast path.

## Results

Three paired runs of `dart run benchmark/experiments/row_map_facade.dart`,
stashing the change for baseline and unstashing for candidate. `containsKey`
row medians (8-column schema, 500,000 inner iterations per measurement):

| Run | Baseline row (ms) | Candidate row (ms) |
|---|---:|---:|
| 1 | 15.329 | 12.377 |
| 2 | 15.231 | 14.707 |
| 3 | 13.930 | 15.101 |

Run-median summary:

| Metric | Value |
|---|---:|
| Baseline median | 15.231 ms |
| Candidate median | 14.707 ms |
| Delta | -3.4% |
| Baseline run-to-run range | 13.930 – 15.329 ms (1.4 ms span) |
| Candidate run-to-run range | 12.377 – 15.101 ms (2.7 ms span) |

The candidate range fully overlaps the baseline range, and the run-3 candidate
median (15.101 ms) is *above* the run-3 baseline median (13.930 ms). The
nominal -3.4% drop in the median is smaller than the per-run variance on
either side.

`Row.operator[]` paths (`hot lookup`, `iterate keys + lookup`) were already
ahead under exp 158, and `Map.from clone`, `forEach`, `entries iteration`, and
`values iteration` do not exercise `containsKey`. None of those moved
materially across the paired runs.

`select_maps` and `point_query` were not re-run as guardrails because internal
resqlite code does not call `Row.containsKey` (`grep -rn 'containsKey' lib/`
returns only the row.dart definition itself and the docstring), and exp 158
already covered the shared `indexOf` path.

## Decision

Reject.

The identity fast path is consistent with exp 158 and the change is
behavior-preserving, but the measured win on the available benchmark is below
the per-run noise floor. The most likely explanation is that
`HashMap<String, int>.containsKey` is already very fast on canonical-string
keys — Dart caches `String.hashCode` on canonical strings, so the bucket lookup
collapses to a single hash + identity-compare. The candidate path replaces that
with a short identity-scan loop that performs almost the same comparison count.

There is also a small downside outside the canonical case: any
`row.containsKey(nonCanonicalKey)` call now pays an up-to-32 element identity
scan before falling through to the HashMap. For typical user code that passes
literal column names this is invisible, but a workload that calls `containsKey`
with many runtime-built strings (e.g. JSON-derived keys) would lose a few
nanoseconds per call.

Without a workload where containsKey-time dominates and the keys are usually
canonical-and-present, the change has no measurable upside on current
benchmarks and a small theoretical downside on the non-canonical path.

## Future Notes

- Do not retry this exact swap unless a new workload makes containsKey on
canonical-string column names a material fraction of wall time. The
obvious candidate would be a streaming consumer that filters rows by
optional-column presence on every emitted row.
- The result is *not* evidence that `RowSchema.indexOf`'s identity fast path
is unnecessary; exp 158 stands. It only shows that the cost reduction
inside the existing `containsKey` shape is below the current measurement
floor.
- If a future result-shape experiment touches `containsKey`, it should
measure on a benchmark that strips the loop-and-switch overhead currently
dominating the `row_map_facade` `containsKey` case, or pair the change
with a workload that calls containsKey on rows transferred from worker
isolates rather than constructed inline.

## Validation

- `dart pub get`
- `dart analyze lib/src/row.dart`
- `dart test test/database_test.dart` (49/49 pass — includes
`row.containsKey('id')` / `row.containsKey('name')` /
`row.containsKey('nonexistent')` assertions)
- Focused identity check confirming
`identical('updated_at', schema.names[5]) == true` on the benchmark schema
- Focused `row_map_facade` A/B (3 baseline + 3 candidate runs, table above)
1 change: 1 addition & 0 deletions experiments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Experiments that didn't work out. Each has valuable context on *why* — check b

| # | Experiment | Why Rejected |
|---|---|---|
| [159](159-row-containskey-identity-fast-path.md) | `Row.containsKey` identity fast path | Routing `Row.containsKey` through `RowSchema.indexOf` to extend exp 158's identity scan landed nominally at -3.4% on the `row_map_facade` `containsKey` row median (15.231 → 14.707 ms), but the candidate range (12.377 – 15.101 ms) fully overlapped the baseline range (13.930 – 15.329 ms). `HashMap<String, int>.containsKey` is already at the noise floor on canonical-string keys because Dart caches `String.hashCode`. The change is behaviour-preserving and exp 158's `indexOf` fast path stands; reopen only if a workload makes `containsKey` on canonical-and-present keys a material wall-time fraction. |
| [151](151-sync-writer-response.md) | Synchronous writer response resolution | Switching writer response futures to `Completer<T>.sync()` was a concrete request-resolution attempt against exp 147's residual writer/request bucket, but the formal Tracelite stream-dispatch A/B did not clear the primary gate. High-cardinality fanout changed +2.92%, keyed-PK subscriptions changed +18.5% with too-noisy evidence, and many-streams writer throughput changed +14.0%. No runtime code kept. |
| [148](148-reader-reply-batching.md) | Reader reply batching | Batching stream re-query replies reduced the exp 136 completion counter in a profile smoke (A11c overlap completion callbacks 4,527 → 1,425, completion wall 109.6 ms → 55.6 ms), but the formal Tracelite stream-dispatch A/B did not produce a measured-elapsed win. High-cardinality fanout changed +5.18%, many-streams writer throughput +3.28%, and keyed-PK subscriptions +13.5%. Do not merge worker-side reader-reply batching without a workload that turns the callback reduction into end-to-end wall improvement. |
| [146](146-lower-batch-pack-threshold.md) | Lower batch packing threshold | Tracelite A/B run over `narrow-batch-insert` collected clean baseline and candidate histories but produced no primary improvement: resqlite changed +1.45% with neutral verdict, while the sqlite_async guardrail was too noisy. Keep the exp 125 large-wide-batch guard; small/narrow batches stay generic until a new workload proves parameter encoding is material. |
Expand Down
19 changes: 16 additions & 3 deletions experiments/signals.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,9 @@
"id": "result-transfer-shape",
"status": "watch",
"subsystems": ["results", "isolate-transfer", "api-shape"],
"currentRead": "The current ResultSet/Row shape is close to optimal for the shipped select() contract. Alternatives often move work rather than remove it, especially once main-isolate consumption is measured. Exp 158 found a narrow exception inside the existing shape: adding a schema-name identity fast path for schemas up to 32 columns plus private HashMap fallback for RowSchema.indexOf roughly halved focused row facade lookup and select_maps main-isolate full-consumption medians without changing transfer or public API, while point-query schema construction stayed neutral/noisy.",
"currentRead": "The current ResultSet/Row shape is close to optimal for the shipped select() contract. Alternatives often move work rather than remove it, especially once main-isolate consumption is measured. Exp 158 found a narrow exception inside the existing shape: adding a schema-name identity fast path for schemas up to 32 columns plus private HashMap fallback for RowSchema.indexOf roughly halved focused row facade lookup and select_maps main-isolate full-consumption medians without changing transfer or public API, while point-query schema construction stayed neutral/noisy. Exp 159 then tried routing Row.containsKey through that same indexOf fast path and rejected the swap: the candidate moved row_map_facade containsKey nominally -3.4% (15.231 -> 14.707 ms) but the candidate range fully overlapped the baseline range, because HashMap<String, int>.containsKey is already at the noise floor on canonical-string keys (Dart caches String.hashCode). The rejection bounds future containsKey-side work in this direction unless a workload makes containsKey on canonical-and-present keys a material wall-time fraction.",
"keyPriors": ["008", "063", "082", "089", "158"],
"archive": ["066", "081"],
"archive": ["066", "081", "159"],
"interestingIf": [
"Dart adds new deeply immutable or transfer primitives that support typed data and lists",
"a change preserves the lean API while removing end-to-end work",
Expand All @@ -192,7 +192,7 @@
}
],
"blockedOnMeasurement": [],
"notesForExperimenters": "Be explicit about API impact. Some measured wins depend on a new public API, which is outside the lean-API goal. Use `select_maps` or a similarly full-consumer benchmark before accepting result-shape changes; setup-only transfer wins are not enough. Exp 158 is a narrow private data-structure win inside RowSchema, not evidence to revive larger ResultSet API changes."
"notesForExperimenters": "Be explicit about API impact. Some measured wins depend on a new public API, which is outside the lean-API goal. Use `select_maps` or a similarly full-consumer benchmark before accepting result-shape changes; setup-only transfer wins are not enough. Exp 158 is a narrow private data-structure win inside RowSchema, not evidence to revive larger ResultSet API changes. Exp 159 took the natural follow-up (extend the identity fast path to Row.containsKey) off the candidate list — the row_map_facade containsKey case collapses to noise because HashMap.containsKey is already fast on canonical-string keys. Do not retry that swap without a workload where containsKey time dominates."
},
{
"id": "measurement-system",
Expand Down Expand Up @@ -684,6 +684,19 @@
"treat `stream_emit_us` as evidence-of-absence for subscriber-fanout optimization candidates until a workload with very many listeners per stream surfaces",
"future main-isolate counters should follow the exp 136 pattern (counter + post-drain snapshot in `audit_workloads.dart`) rather than redoing the harness wiring"
]
},
"159": {
"directions": ["result-transfer-shape"],
"outcomeClass": "rejected_below_current_signal",
"changedBeliefs": [
"HashMap<String, int>.containsKey is already at the row_map_facade noise floor on canonical-string keys, so extending exp 158's identity fast path to Row.containsKey produces no measurable win",
"Future result-transfer-shape work should not retry the containsKey side of the identity fast path unless a workload shows containsKey on canonical-and-present keys is a material wall-time fraction"
],
"nextSignals": [
"do not route Row.containsKey through RowSchema.indexOf without a new workload that exposes containsKey wall time",
"if a containsKey-heavy stream consumer or map-presence-filter benchmark appears, re-run the swap and confirm the identity scan actually fires (canonical-string identity holds on the row_map_facade schema, so the fast path is reachable when measured)",
"exp 158's indexOf fast path stands; this rejection only bounds the containsKey-side extension"
]
}
}
}
Loading