Metadata-Version: 2.4
Name: slackwater-tminus
Version: 0.1.0
Summary: Predict-and-confirm timing system that replaces polling with countdown events measured in beats.
Author: Casey DiGennaro
License: MIT
Keywords: t-minus,countdown,prediction,tempo,slackwater,lucineer
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Provides-Extra: tempo
Requires-Dist: slackwater-tempo>=0.1.0; extra == "tempo"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"

# slackwater-tminus

![tests](https://img.shields.io/badge/tests-103%20passed-brightgreen)
![version](https://img.shields.io/badge/version-0.1.0-blue)
![python](https://img.shields.io/badge/python-3.10%2B-blue)

Predict-and-confirm timing that replaces polling. Declare future events with predicted completion times in beats. Subscribers confirm readiness. When quorum is met and the countdown reaches zero, precompiled scripts fire — zero latency, one notification, no polling. Countdowns are measured in beats, not seconds, integrating directly with `slackwater-tempo`'s BeatClock.

## Installation

```bash
pip install slackwater-tminus
```

## Core Concept

**Polling:** "Is it done yet? Is it done yet? Is it done yet?" — N messages.

**T-Minus:** "It will be done at beat 16." + "Confirmed." — 2 messages. The fire is just the trigger pull.

For a 60-second job polled at 0.5s intervals, T-Minus saves 118 messages (60× reduction).

## API Reference

### CountdownEvent

```python
from slackwater_tminus import CountdownEvent, CountdownState

CountdownEvent(
    name: str,
    predicted_beat: float,
    id: str = <auto>,
    quorum: int = 1,
    script: Callable | None = None,
    metadata: dict = {},
)
```

The core primitive. A future event with a predicted completion beat.

**State machine:**

```
PENDING → PREDICTED → CONFIRMED → FIRED
               ↘         ↘
              MISSED    MISSED
```

| State | Meaning |
|---|---|
| `PENDING` | Event declared, prediction not committed |
| `PREDICTED` | Prediction committed, awaiting confirmations |
| `CONFIRMED` | Quorum reached, will fire at predicted beat |
| `FIRED` | Beat arrived, scripts executed, subscribers notified |
| `MISSED` | Prediction wrong or quorum not reached in time |

**Lifecycle methods:**

```python
event.commit_prediction() -> None
event.subscribe(subscriber_id: str) -> None          # auto-commits if PENDING
event.confirm(subscriber_id: str) -> bool            # True if quorum reached
event.defer(subscriber_id: str, reason: str = "") -> None
event.miss(subscriber_id: str = "", reason: str = "") -> None
event.fire(actual_beat: float | None = None) -> Any  # executes script
event.force_miss(reason: str = "forced") -> None
```

**Properties:**

```python
event.state -> CountdownState
event.quorum_met -> bool
event.is_terminal -> bool             # FIRED or MISSED
event.accuracy -> float | None        # 1.0 = perfect, None if not fired
event.subscribers -> frozenset[str]
event.confirmations -> frozenset[str]
event.deferrals -> dict[str, str]
event.missed_by -> frozenset[str]
event.result -> Any                   # script return value
```

**Accuracy formula:**

```
accuracy = max(0.0, 1.0 − |predicted_beat − actual_beat| / max(predicted_beat, 1.0))
```

### TMinusPredictor

```python
from slackwater_tminus import TMinusPredictor, PredictionResult

TMinusPredictor(
    bpm: float = 60.0,
    start_beat: float = 0.0,
)
```

Orchestrates countdown events. Maintains a beat timeline, advances through it, and fires events when their predicted beats arrive.

**Prediction:**

```python
event = predictor.predict(
    name: str,
    beats_ahead: float,
    *,
    quorum: int = 1,
    script: Callable | None = None,
    confidence: float = 0.8,
    metadata: dict | None = None,
) -> CountdownEvent
```

Creates an event at `current_beat + beats_ahead` and immediately commits to PREDICTED.

**Advancing time:**

```python
fired: list[CountdownEvent] = predictor.advance(beats: float)
fired: list[CountdownEvent] = predictor.tick()  # shortcut for advance(1)
```

Returns events that FIRED at this tick. Events whose predicted beat arrived with quorum met → FIRED. Without quorum → MISSED.

**Queries:**

```python
predictor.predict_next() -> CountdownEvent | None    # nearest pending
predictor.countdown_beats() -> float | None          # beats to next event
predictor.countdown_seconds() -> float | None        # wall-clock estimate
predictor.get(event_id) -> CountdownEvent | None
predictor.get_by_name(name: str) -> list[CountdownEvent]
predictor.pending_events -> list[CountdownEvent]
predictor.fired_events -> list[CountdownEvent]
predictor.missed_events -> list[CountdownEvent]
predictor.avg_accuracy -> float
```

**Calibration:**

```python
predictor.calibrate() -> dict[str, float]
```

Returns `avg_accuracy`, `avg_lead_time_beats`, `fire_rate`, `total_predictions`, `fired`, `missed`.

```python
predictor.message_savings(polling_interval_s: float = 0.5) -> dict[str, int]
```

Compares T-Minus message count vs polling. Returns `polling_messages`, `tminus_messages`, `savings_ratio`.

### PrecompiledScript

```python
from slackwater_tminus import PrecompiledScript
from slackwater_tminus.precompiled import compile

PrecompiledScript(
    action: Callable[..., Any],
    label: str = "",
    args: tuple = (),
    kwargs: dict = {},
)

# Convenience function
script = compile("tower_build", build_function, height=10, material="stone)
```

Actions attached to predictions, ready for zero-latency execution. The work of figuring out WHAT to do is done during the countdown. The fire is the trigger pull.

```python
script.execute() -> Any           # idempotent — returns cached result
script.is_executed -> bool
script.result -> Any
script.latency_ms -> float | None  # lead time: compile→execute
```

### Subscriber

```python
from slackwater_tminus import Subscriber, SubscriberState

Subscriber(id: str)
```

A participant in the predict-and-confirm cycle. Tracks state across multiple events independently.

**Subscriber state machine (per event):**

```
UNINFORMED → PENDING → CONFIRMED → NOTIFIED
                ↘         ↘
              DEFERRED   MISSED
```

```python
sub.subscribe(event_id: str) -> None
sub.confirm(event_id: str) -> SubscriberState    # → CONFIRMED
sub.defer(event_id: str, reason: str = "") -> SubscriberState  # → DEFERRED
sub.miss(event_id: str, reason: str = "") -> SubscriberState   # → MISSED (terminal)
sub.notify(event_id: str) -> SubscriberState     # → NOTIFIED
```

**Bulk operations:**

```python
sub.confirm_all(event_ids: list[str]) -> None
sub.stats() -> dict[str, int]    # counts by state name
```

**Properties:**

```python
sub.event_ids -> frozenset[str]
sub.confirmed_events -> frozenset[str]
sub.deferred_events -> frozenset[str]
sub.missed_events -> frozenset[str]
sub.state_for(event_id) -> SubscriberState
```

### BeatClock

```python
from slackwater_tminus import BeatClock

BeatClock(bpm: float = 60.0, current_beat: float = 0.0)
```

A clock that counts beats instead of seconds. Can auto-sync to wall-clock time or be advanced manually.

```python
clock.sync() -> float                    # sync with wall-clock time
clock.advance(beats: float) -> float     # manual advance
clock.beats_to_seconds(beats: float) -> float
clock.seconds_to_beats(seconds: float) -> float
clock.set_bpm(bpm: float, *, resync: bool = True) -> None
clock.beat_duration -> float             # 60.0 / bpm
```

### BeatCountdown

```python
from slackwater_tminus import BeatCountdown

BeatCountdown(clock: BeatClock | None = None, bpm: float = 60.0)
```

Thin wrapper around TMinusPredictor using a BeatClock for timing. All durations are in beats. Clock and events are coupled — advancing the clock fires events.

```python
event = bc.schedule("build_complete", beats=16, quorum=1) -> CountdownEvent
bc.subscribe(event_id, subscriber_id) -> CountdownEvent
bc.confirm(event_id, subscriber_id) -> bool      # True if quorum reached
bc.defer(event_id, subscriber_id, reason) -> None
bc.advance(beats) -> list[CountdownEvent]        # advances clock + predictor
bc.tick() -> list[CountdownEvent]                # advance one beat
bc.sync() -> list[CountdownEvent]                # wall-clock sync
bc.remaining_beats(event_id) -> float | None
bc.remaining_seconds(event_id) -> float | None
```

## Examples

### Full predict → confirm → fire cycle

```python
from slackwater_tminus import TMinusPredictor, PrecompiledScript

predictor = TMinusPredictor(bpm=60)
script = PrecompiledScript(
    action=lambda: print("Castle complete!"),
    label="castle_build",
)

event = predictor.predict("castle_complete", beats_ahead=16, script=script.execute)
event.subscribe("player_1")
event.confirm("player_1")  # quorum=1, so this confirms
assert event.state.name == "CONFIRMED"

fired = predictor.advance(16)
assert event in fired
assert script.is_executed  # script ran with zero planning latency
```

### Quorum with multiple subscribers

```python
predictor = TMinusPredictor(bpm=60)
event = predictor.predict("consensus_vote", beats_ahead=32, quorum=3)

for agent in ["alice", "bob", "carol"]:
    event.subscribe(agent)
    event.confirm(agent)

# After all 3 confirm → CONFIRMED. Advance to fire.
fired = predictor.advance(32)
```

### Beat-space scheduling with tempo change

```python
from slackwater_tminus import BeatCountdown

bc = BeatCountdown(bpm=60)
event = bc.schedule("build", beats=16)
bc.confirm(event.id, "a")

bc.advance(8)  # halfway
print(bc.remaining_beats(event.id))   # 8.0

bc.clock.set_bpm(120, resync=False)   # tempo doubles
print(bc.remaining_seconds(event.id)) # 4.0 (halved)
print(bc.remaining_beats(event.id))   # 8.0 (unchanged — beats are tempo-relative)

fired = bc.advance(8)  # fire
```

### Message savings vs polling

```python
predictor = TMinusPredictor(bpm=60)
event = predictor.predict("long_job", beats_ahead=60)  # 60-second job
event.subscribe("a")
event.confirm("a")
predictor.advance(60)

savings = predictor.message_savings(polling_interval_s=0.5)
print(savings)
# {'polling_messages': 120, 'tminus_messages': 2, 'savings_ratio': 60.0}
```

## License

MIT
