Files
manifoldbt/docs/sweep-combo-limit-plan.md
T
2026-08-16 12:02:58 +00:00

178 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Community sweep gating — shipped design
Two mechanisms, both in `crates/bt-python/src/lib.rs` around
`require_combo_limit`:
1. a **cumulative combo budget** per process (256), and
2. a **wall-clock rate gate** — 5 s between Community sweeps, serialised
machine-wide by an exclusive file lock.
Pro is untouched: the gate returns before both.
Measured effect on the bypass this exists to stop (one fresh interpreter per
slice, since the counter dies with the process):
| strategy | combos | Pro | bypass, sequential | bypass, parallel | friction |
|-------------------|--------|--------|--------------------|------------------|----------|
| RSI + EMA (light) | 5,000 | 5.9 s | 103 s | 95 s | 16.2x |
| RSI + EMA (light) | 20,000 | 23.3 s | 413 s | 390 s | 16.7x |
| SMA bands (heavy) | 5,000 | 10.0 s | 107 s | 95 s | 9.5x |
| SMA bands (heavy) | 20,000 | 40.0 s | 424 s | 391 s | 9.8x |
Friction is stable *within* a strategy (16.2 → 16.7, 9.5 → 9.8) and differs
*between* them: it no longer depends on grid size at all, only on how expensive
the strategy is per combination. At the previous 500-combo cap the same four
cells read 7.7x / 8.7x / 5.1x / 5.2x — halving the cap roughly doubled them, as
the model predicts.
Every cell read **1.0x** before this work: slicing a grid across processes cost
the same as one big licensed call.
## The hole
The Community limit (`COMMUNITY_MAX_SWEEP_COMBOS`, 500 at the time) used to be
checked **per call**. A parameter grid is sliceable, so splitting it bypassed
the cap:
```python
for i in range(0, 500, 5): # 100 calls x 500 combos
bt.run_sweep_lite(strat, {"dev_up": DU[i:i+5], "dev_dn": DD}, cf, store)
```
| run | time | per combo |
|------------------------------------|--------|-----------|
| 1 call x 50,000 combos (Pro) | 89.9 s | 1.80 ms |
| 100 calls x 500 combos (Community) | 90.3 s | 1.81 ms |
The split cost **+0.5%**, and per-call overhead is ~10 ms once data is cached,
so no per-call charge could make slicing expensive without hitting legitimate
small sweeps first.
## 1. Cumulative combo budget
A process-lifetime `AtomicU64`. Non-Pro calls are refused when
`total + n_combos > COMMUNITY_MAX_SWEEP_COMBOS`; otherwise the total is
CAS-incremented — a plain load+store would let two concurrent sweeps both slip
under the cap. A refused call consumes no budget and pays no wait. Pro skips
check and accounting. `_native._combo_budget()` exposes `(used, limit, is_pro)`
read-only.
All native fan-out entry points share one total: `run_sweep`, `run_sweep_lite`,
`run_batch`, `run_batch_lite`, `run_sweep_2d`, `run_stability`. A single
`bt.run()` is ungated.
The Python-side `_require_pro_over_combos` stays a fast-fail courtesy check for
the single-call case; the authoritative gate is native, since the Python layer
is trivially patchable.
This alone leaves the restart bypass wide open — the counter dies with the
process — which is what mechanism 2 prices.
## 2. Rate gate: 5 s, serialised by a file lock
Before each accepted Community call, the gate waits `SWEEP_MIN_INTERVAL` (5 s)
while holding an exclusive lock on `{license_dir}/sweep.lock`.
**The lock is the mechanism, not plumbing.** Waiting costs no cores, so N
concurrent processes would otherwise all clear the same 5 s — the way every
sleep-based limiter dies. Serialising the wait behind one machine-wide lock
makes N slices cost N x 5 s whatever the core count. The measurements confirm
it exactly: 19 slices in parallel took 95 s and 78 took 390 s, against a
theoretical floor of 95 s and 390 s. Parallelising buys nothing.
**Stateless on purpose.** An earlier shape recorded "last sweep at T" and only
waited the remainder — friendlier, since an idle user pays nothing. But the
record is the attack surface: restoring a copy from a minute ago grants an
immediate pass, and a MAC prevents forgery, not replay (a *stale* restored file
is the permissive one, so replay always favours the attacker). Here the file
carries no data, only the lock. Nothing to roll back; deleting it merely makes
the next caller recreate it, and the wait still happens.
**Fails closed on an unwritable state directory** (read-only HOME, locked-down
CI): the wait still happens, only without cross-process serialisation. Failing
open would make an unwritable HOME the bypass.
**Paid up front**, before results exist, so killing the process to dodge the
wait also discards the run. The GIL is released throughout.
Implemented with `std::fs::File::lock` — stable since Rust 1.89, so no new
dependency, but that is the minimum toolchain for this crate now.
### Why not a CPU penalty (what this replaced)
The previous mechanism burned ~3 s of counted CPU per process. It worked
(3.7x6.2x) but had three defects the rate gate does not:
- **Cost in core-seconds, not seconds.** Friction was
`1 + fixed_cost / (Pro cost of the slice)`, so the same penalty bought 6.2x on
a light strategy, 3.9x on a heavy one, and would fall to ~1.2x on 3 years of
bars. The rate gate's floor is wall-clock, so strategy weight and dataset size
drop out.
- **Machine-dependent**, needing a runtime calibration and per-thread clamps to
stay near 3 s across CPUs. The rate gate needs neither.
- **Wasteful and visible.** Burning every core for 3 s is indefensible if a
user profiles it. Waiting costs nothing and leaves the machine usable.
## Tuning
Behaviour is now predictable:
```
friction ≈ (combos / cap) x 5 s / (Pro time for those combos)
```
The cap is therefore the lever, and its effect is calculable rather than
measured: halving it roughly doubles the friction, at the cost of a smaller free
allowance. Verified — moving from 500 to 256 took the four cells above from
5.1x8.7x to 9.5x16.7x, a 1.9x shift against a predicted 1.95x.
Current settings: `COMMUNITY_MAX_SWEEP_COMBOS = 256`, `SWEEP_MIN_INTERVAL = 5 s`.
A legitimate free user pays 5 s per sweep, capped at 256 combinations per
process — still a 16x16 grid.
## Rejected alternatives
- **Disk-persisted counter, or a required "ticket" file.** Deleted with `rm`,
or restored with `cp`; a hash prevents forgery, not replay.
- **Shared memory / sentinel process.** One `pkill` away, and a library
spawning hidden background processes is an antivirus profile on Windows.
- **Delay measured from a recorded last-sweep time.** Needs history, which is
restorable — see above. The stateless always-wait shape avoids it.
- **Plain sleep without the lock.** Overlaps for free across processes; this is
the single reason the lock exists.
- **Penalty scaled by combos, or by combos x dataset span.** Measured and
dropped: a legitimate 500-combo sweep reached 3 s and 4.4 s respectively.
- **`run_sweep_lite` restricted to Pro.** Would work — capability cannot be
sliced around — but turned down on positioning: the free tier should be
capped in *how much*, not degraded in *how well*.
## Known limits
- **The clock is the user's.** `faketime`/`LD_PRELOAD` can shorten the sleep,
and per-slice mount namespaces (`unshare`) give each process its own lock
file. Both are deliberate technical acts, unlike "restart Python".
- **A patched wheel defeats any client-side check.** This is the ceiling of the
whole approach; the goal is to make bypassing cost more than a subscription,
not to make it impossible.
- Escalation path if telemetry ever shows this is not enough:
`docs/sweep-key-rate-limit-plan.md`.
## Related findings (tracked separately)
1. `run_sweep` (full Arrow output) is OOM-killed around ~6.8 MB/combo: 2,000
combos ≈ 13.4 GB RSS, 3,000 → SIGKILL, no Python exception, no warning. Only
Pro users can reach it, since the Community cap keeps grids below the danger
zone — a paying-customer-only failure. Needs an upfront memory estimate and
a clean error pointing at `run_sweep_lite`.
2. Level-fill via `execution_source` regenerates the execution bar feed per
combination (~80x slower sweeps); an expression-based execution price would
move the level back into parameter space. Separate design.
## Measurement note
Per-combo cost is flat across grid size — 1.21 / 1.13 / 1.11 / 1.17 / 1.11
ms/combo at 500 / 2k / 10k / 50k / 200k on one strategy. An earlier revision of
this document claimed the big call degraded at scale; that was wrong, and came
from comparing grids with different *content*. Per-combo cost tracks trade
count, not grid size — which is also why friction varies by strategy.