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

8.5 KiB
Raw Blame History

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:

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.
  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.