From 70beecd615a2a8585741453f54fd668ebf78e230 Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Mon, 27 Apr 2026 17:59:30 +0200 Subject: [PATCH] Prevent repeated watch loops --- START_HERE.md | 9 ++++ docs/RATCHET_OPERATIONS.md | 2 + reports/EXPERIMENT_LOG.md | 22 ++++++++ reports/run-20260427T135327Z-222826.md | 73 +++++++++++++++++++++++++ scripts/ratchet | 2 +- src/braiins_ratchet/guidance.py | 69 +++++++++++++++++++++++- tests/test_guidance.py | 74 ++++++++++++++------------ 7 files changed, 214 insertions(+), 37 deletions(-) create mode 100644 reports/run-20260427T135327Z-222826.md diff --git a/START_HERE.md b/START_HERE.md index 5e329d6..7286cc8 100644 --- a/START_HERE.md +++ b/START_HERE.md @@ -39,6 +39,15 @@ You do not need to babysit it. It will: If you want the technical report, run `./scripts/ratchet report`. The normal workflow intentionally shows the cockpit first. +After a watch finishes, the cockpit enters a post-watch cooldown. That is deliberate. + +Post-watch cooldown means: + +1. The current experimental stage is complete. +2. Starting another identical watch immediately is not useful ratcheting. +3. The run report is the evidence artifact. +4. The next planned touch is a later fresh sample, usually `./scripts/ratchet once`. + ## Research Pathway The cockpit has two different time horizons: diff --git a/docs/RATCHET_OPERATIONS.md b/docs/RATCHET_OPERATIONS.md index c8de222..0d420f6 100644 --- a/docs/RATCHET_OPERATIONS.md +++ b/docs/RATCHET_OPERATIONS.md @@ -48,6 +48,8 @@ At the end of a normal watch, inspect the ratchet record: The ledger is the main artifact. It says what was tested, how long it ran, what actions the strategy would have considered, and what adaptation should be considered next. +After a completed watch, the cockpit should not immediately recommend another identical watch. It enters post-watch cooldown, which means the current stage is complete and the next useful operator touch is a later fresh sample. + If a run already happened before automatic bookkeeping was available, reconstruct it from the stored SQLite snapshots: ```bash diff --git a/reports/EXPERIMENT_LOG.md b/reports/EXPERIMENT_LOG.md index 916cc9b..b21e060 100644 --- a/reports/EXPERIMENT_LOG.md +++ b/reports/EXPERIMENT_LOG.md @@ -25,3 +25,25 @@ Karpathy-style ratchet rule: every run states a hypothesis, collects data, score - action_counts: manual_canary=24 - report: reports/retro-2026-04-25T19-08-00-00-00.md - adaptation: The run repeatedly found bounded canary conditions, but modeled net was negative on average. Next ratchet: do not escalate spend; test a smaller depth target or a lower overpay cushion. + +## run-20260427T135327Z-222826 + +- status: running +- started_utc: 2026-04-27T13:53:27+00:00 +- planned_cycles: 24 +- interval_seconds: 300 +- planned_duration_minutes: 120.0 +- hypothesis: Depth-aware fillable price plus a small overpay cushion is a better canary trigger than raw best ask. +- plan: collect public Braiins depth, collect OCEAN state, compute shadow canary, store every proposal. +- operator_action: none by default; manual action only if report later says manual_canary or manual_bid and operator agrees. + +- status_update: completed +- ended_utc: 2026-04-27T15:48:52+00:00 +- collected_samples: 24 +- action_counts: manual_canary=24 +- strategy_price_min_avg_max: 0.47763 / 0.4798921698412225 / 0.48492 +- expected_net_min_avg_max_btc: -0.00000383101148933686517479144602 / -0.000002821503981988926325106899014 / -0.00000236319764547711127977695701 +- latest_action: manual_canary +- latest_reason: bounded research canary: profit guardrails not cleared, but expected_net=-0.00000238363529903760002629072482 is within loss budget 0.000025 +- report: reports/run-20260427T135327Z-222826.md +- adaptation: The run repeatedly found bounded canary conditions, but modeled net was negative on average. Next ratchet: do not escalate spend; test a smaller depth target or a lower overpay cushion. diff --git a/reports/run-20260427T135327Z-222826.md b/reports/run-20260427T135327Z-222826.md new file mode 100644 index 0000000..2918fc0 --- /dev/null +++ b/reports/run-20260427T135327Z-222826.md @@ -0,0 +1,73 @@ +# run-20260427T135327Z-222826 + +## Ratchet Question + +Depth-aware fillable price plus a small overpay cushion is a better canary trigger than raw best ask. + +## Run Summary + +- started_utc: 2026-04-27T13:53:27+00:00 +- ended_utc: 2026-04-27T15:48:52+00:00 +- planned_cycles: 24 +- interval_seconds: 300 +- collected_samples: 24 +- first_sample_utc: 2026-04-27T13:53:28+00:00 +- last_sample_utc: 2026-04-27T15:48:52+00:00 +- action_counts: manual_canary=24 +- strategy_price_min_avg_max: 0.47763 / 0.4798921698412225 / 0.48492 +- expected_net_min_avg_max_btc: -0.00000383101148933686517479144602 / -0.000002821503981988926325106899014 / -0.00000236319764547711127977695701 + +## Interpretation + +The run repeatedly found bounded canary conditions, but modeled net was negative on average. Next ratchet: do not escalate spend; test a smaller depth target or a lower overpay cushion. + +## Operator Reading + +This run collected 24 public Braiins market samples. The strategy outcomes were: manual_canary=24. Expected net ranged from -0.00000383101148933686517479144602 to -0.00000236319764547711127977695701 BTC. For ratcheting, do not ask whether one line was green or red. Ask whether this run changed one control knob: depth target, overpay cushion, canary spend, duration, or timing window. + +## Current Full Report Context + +The block below is the latest complete human report available when this markdown was written. For retroactive reports, the authoritative reconstructed run data is the summary above. + +```text +Braiins Ratchet Report + +OCEAN snapshot: 2026-04-27T15:48:52+00:00 + pool_hashrate_eh_s: 15.51 + network_difficulty_t: 135.59 + share_log_window_t: 1084.76 + avg_block_time_hours: 9 + +Braiins market snapshot: 2026-04-27T15:48:52+00:00 + status: SPOT_INSTRUMENT_STATUS_ACTIVE + best_bid_btc_per_eh_day: 0.50343 + best_ask_btc_per_eh_day: 0.46075 + fillable_target_ph: 2 + fillable_price_btc_per_eh_day: 0.46773 + suggested_bid_btc_per_eh_day: 0.47773 + last_price_btc_per_eh_day: 0.47933997316187 + available_hashrate_eh_s: 1.91368833114474 + sampled_strategy_price_count: 50 + sampled_strategy_price_min_avg_max: 0.46103 / 0.4792744415237868 / 0.48492 + +Strategy action: manual_canary + reason: bounded research canary: profit guardrails not cleared, but expected_net=-0.00000238363529903760002629072482 is within loss budget 0.000025 + breakeven_btc_per_eh_day: 0.4663426590859076733944013201 + expected_reward_btc: 0.00009761636470096239997370927518 + expected_net_btc: -0.00000238363529903760002629072482 + score_btc: -0.00003654936294437444001708897113 + maturity: treat canary as immature for about 72 hours after spend + proposed_price_btc_per_eh_day: 0.47773 + proposed_spend_btc: 0.00010 + proposed_duration_minutes: 180 + implied_hashrate_eh_s: 0.001674586063257488539551629582 + +Plain English + Decision: a tiny manual research canary is allowed by the loss budget, but this is not a profit signal. + Market depth: the visible best ask is 0.46075, but enough depth for 2 PH/s starts at 0.46773 (gap 0.00698). + Price check: proposed price is 0.47773; estimated breakeven is 0.4663426590859076733944013201; edge is -0.0113873409140923266055986799 BTC/EH/day. + Manual canary card: spend 0.00010 BTC (~10000 sats), duration 180 minutes, estimated speed 1.674586063257488539551629582 PH/s. + Expected result: -0.00000238363529903760002629072482 BTC (~-238 sats) before luck; this is a model estimate, not a promise. + Wait time: treat canary as immature for about 72 hours after spend. + Rule: manual_canary means buying information with bounded downside; manual_bid means the stricter profit-seeking guardrails cleared. +``` diff --git a/scripts/ratchet b/scripts/ratchet index 1c59baa..d8a6b32 100755 --- a/scripts/ratchet +++ b/scripts/ratchet @@ -97,7 +97,7 @@ cmd_watch() { echo echo "Watch finished. Reading the cockpit now." echo - run_python -m braiins_ratchet.cli next + BRAIINS_RATCHET_IGNORE_PROCESS_WATCH=1 run_python -m braiins_ratchet.cli next return "$status" } diff --git a/src/braiins_ratchet/guidance.py b/src/braiins_ratchet/guidance.py index f4262d5..9982de8 100644 --- a/src/braiins_ratchet/guidance.py +++ b/src/braiins_ratchet/guidance.py @@ -9,6 +9,8 @@ import subprocess from .experiments import ACTIVE_WATCH, EXPERIMENT_LOG, REPORTS_DIR from .storage import latest_market_snapshot, latest_ocean_snapshot, latest_proposal +POST_WATCH_COOLDOWN_MINUTES = 360 + def build_operator_cockpit(conn) -> str: ocean = latest_ocean_snapshot(conn) @@ -17,6 +19,7 @@ def build_operator_cockpit(conn) -> str: latest_report = _latest_report() running_runs = _running_runs() active_watch = _active_watch() + completed_watch = _recent_completed_watch(latest_report, market.timestamp_utc if market else None) freshness = _freshness_minutes(market.timestamp_utc if market else None) is_fresh = freshness is not None and freshness <= 30 @@ -32,6 +35,7 @@ def build_operator_cockpit(conn) -> str: f" Latest run report: {latest_report or 'none yet'}", f" Experiment ledger: {EXPERIMENT_LOG.relative_to(REPORTS_DIR.parent) if EXPERIMENT_LOG.exists() else 'none yet'}", f" Active watch: {active_watch or 'none detected'}", + f" Research stage: {_research_stage(active_watch, completed_watch)}", ] if running_runs: @@ -41,6 +45,7 @@ def build_operator_cockpit(conn) -> str: lines.extend( _do_this_now( active_watch=active_watch, + completed_watch=completed_watch, has_ocean=ocean is not None, has_market=market is not None, is_fresh=is_fresh, @@ -51,6 +56,7 @@ def build_operator_cockpit(conn) -> str: lines.extend( _pathway_forecast( active_watch=active_watch, + completed_watch=completed_watch, has_ocean=ocean is not None, has_market=market is not None, is_fresh=is_fresh, @@ -83,6 +89,7 @@ def build_operator_cockpit(conn) -> str: def _do_this_now( active_watch: str | None, + completed_watch: tuple[str, int] | None, has_ocean: bool, has_market: bool, is_fresh: bool, @@ -97,6 +104,19 @@ def _do_this_now( " ./scripts/ratchet", ] + if completed_watch and action == "manual_canary": + report_path, age_minutes = completed_watch + remaining = max(0, POST_WATCH_COOLDOWN_MINUTES - age_minutes) + return [ + " STOP.", + f" The latest 2-hour watch already finished {age_minutes} minutes ago.", + f" Report written: {report_path}", + " Do not start another identical watch now.", + f" Next planned operator touch: after about {remaining} minutes, run exactly:", + " ./scripts/ratchet once", + " Reason: this ratchet stage is complete; repeating it immediately would be loop-chasing, not research.", + ] + if not has_ocean or not has_market: return [ " Run exactly:", @@ -140,6 +160,7 @@ def _do_this_now( def _pathway_forecast( active_watch: str | None, + completed_watch: tuple[str, int] | None, has_ocean: bool, has_market: bool, is_fresh: bool, @@ -153,6 +174,15 @@ def _pathway_forecast( " Longterm, possible: adjust one strategy knob if the report says the run taught us something.", ] + if completed_watch and action == "manual_canary": + report_path, _age_minutes = completed_watch + return [ + " Planning probabilities are workflow estimates, not profit probabilities.", + f" Immediate, certain: stop this stage and keep {report_path} as the evidence artifact.", + " Midterm, likely: after cooldown, refresh with one once command and compare against this report.", + " Longterm, possible: adjust exactly one knob only if repeated reports show the same pattern.", + ] + if not has_ocean or not has_market: return [ " Planning probabilities are workflow estimates, not profit probabilities.", @@ -228,6 +258,32 @@ def _latest_report() -> str | None: return str(reports[0].relative_to(REPORTS_DIR.parent)) +def _recent_completed_watch(latest_report: str | None, latest_market_timestamp: str | None) -> tuple[str, int] | None: + if latest_report is None: + return None + report_path = REPORTS_DIR.parent / latest_report + if not report_path.name.startswith("run-") or not report_path.exists(): + return None + market_dt = _parse_utc(latest_market_timestamp) + if market_dt is not None and report_path.stat().st_mtime < market_dt.timestamp(): + return None + age_seconds = datetime.now(UTC).timestamp() - report_path.stat().st_mtime + age_minutes = max(0, int(age_seconds // 60)) + if age_minutes > POST_WATCH_COOLDOWN_MINUTES: + return None + return latest_report, age_minutes + + +def _research_stage(active_watch: str | None, completed_watch: tuple[str, int] | None) -> str: + if active_watch: + return "watch running" + if completed_watch: + report_path, age_minutes = completed_watch + remaining = max(0, POST_WATCH_COOLDOWN_MINUTES - age_minutes) + return f"post-watch cooldown ({remaining} minutes left, report {report_path})" + return "ready" + + def _running_runs() -> list[str]: if not EXPERIMENT_LOG.exists(): return [] @@ -266,6 +322,8 @@ def _active_watch_from_state_file() -> str | None: def _active_watch_from_process_table() -> str | None: + if os.environ.get("BRAIINS_RATCHET_IGNORE_PROCESS_WATCH") == "1": + return None try: output = subprocess.check_output( ["ps", "-axo", "pid=,command="], @@ -302,6 +360,14 @@ def _pid_exists(pid: int) -> bool: def _freshness_minutes(timestamp_utc: str | None) -> int | None: + parsed = _parse_utc(timestamp_utc) + if parsed is None: + return None + age = datetime.now(UTC) - parsed + return max(0, int(age.total_seconds() // 60)) + + +def _parse_utc(timestamp_utc: str | None) -> datetime | None: if not timestamp_utc: return None try: @@ -310,8 +376,7 @@ def _freshness_minutes(timestamp_utc: str | None) -> int | None: return None if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=UTC) - age = datetime.now(UTC) - parsed.astimezone(UTC) - return max(0, int(age.total_seconds() // 60)) + return parsed.astimezone(UTC) def _freshness_text(freshness: int | None) -> str: diff --git a/tests/test_guidance.py b/tests/test_guidance.py index caa5d7e..fbba845 100644 --- a/tests/test_guidance.py +++ b/tests/test_guidance.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime import sqlite3 import unittest -from braiins_ratchet.guidance import build_operator_cockpit +from braiins_ratchet.guidance import _do_this_now, _pathway_forecast, build_operator_cockpit from braiins_ratchet.models import CandidateOrder, MarketSnapshot, OceanSnapshot, StrategyProposal from braiins_ratchet.storage import init_db, save_market_snapshot, save_ocean_snapshot, save_proposal @@ -66,44 +66,50 @@ class GuidanceTests(unittest.TestCase): self.assertIn("Longterm, possible", text) def test_stale_market_data_routes_operator_to_once(self) -> None: - conn = sqlite3.connect(":memory:") - init_db(conn) - save_ocean_snapshot( - conn, - OceanSnapshot( - timestamp_utc="2000-01-01T00:00:00+00:00", - pool_hashrate_eh_s=Decimal("16.95"), - ), - ) - save_market_snapshot( - conn, - MarketSnapshot( - timestamp_utc="2000-01-01T00:00:01+00:00", - best_price_btc_per_eh_day=Decimal("0.48031"), - source="braiins-public", - ), - ) - save_proposal( - conn, - StrategyProposal( - action="manual_canary", - reason="inside research loss budget", - order=None, - breakeven_btc_per_eh_day=Decimal("0.46634"), - expected_reward_btc=Decimal("0.000097"), - expected_net_btc=Decimal("-0.000003"), - score_btc=Decimal("-0.000037"), - maturity_note="treat canary as immature", - ), + lines = _do_this_now( + active_watch=None, + completed_watch=None, + has_ocean=True, + has_market=True, + is_fresh=False, + action="manual_canary", ) - text = build_operator_cockpit(conn) + text = "\n".join(lines) - self.assertIn("Braiins sample freshness: stale", text) self.assertIn("./scripts/ratchet once", text) self.assertIn("latest Braiins sample is stale", text) - self.assertIn("DO THIS NOW", text) - self.assertIn("if the fresh state still says manual_canary", text) + + def test_recent_completed_watch_stops_identical_watch_loop(self) -> None: + lines = _do_this_now( + active_watch=None, + completed_watch=("reports/run-example.md", 4), + has_ocean=True, + has_market=True, + is_fresh=True, + action="manual_canary", + ) + + text = "\n".join(lines) + + self.assertIn("STOP.", text) + self.assertIn("Do not start another identical watch now.", text) + self.assertIn("./scripts/ratchet once", text) + + def test_recent_completed_watch_forecast_enters_cooldown(self) -> None: + lines = _pathway_forecast( + active_watch=None, + completed_watch=("reports/run-example.md", 4), + has_ocean=True, + has_market=True, + is_fresh=True, + action="manual_canary", + ) + + text = "\n".join(lines) + + self.assertIn("Immediate, certain: stop this stage", text) + self.assertIn("Midterm, likely: after cooldown", text) if __name__ == "__main__":