Skip to content

Add ball-table resolver strategies#297

Merged
ekiefl merged 3 commits into
mainfrom
ek/ball-table-resolver
May 18, 2026
Merged

Add ball-table resolver strategies#297
ekiefl merged 3 commits into
mainfrom
ek/ball-table-resolver

Conversation

@ekiefl
Copy link
Copy Markdown
Owner

@ekiefl ekiefl commented May 18, 2026

Summary

Introduce the ball-table event resolver. After
this PR, Resolver carries a ball_table strategy field, BALL_TABLE events
have a dispatch branch, and two ball-table collision models are importable. The
2D simulation is byte-identical to before — nothing currently generates
BALL_TABLE events, so the new dispatch branch is dormant. A detection strategy would activate that.

This builds on the previous PRs today (SimulationEngine infrastructure for 2D/3D modes, and vendored airborne primitives — motion state, BALL_TABLE event type,
parabolic evolution).

What's in this PR

Resolver package (pooltool/physics/resolve/ball_table/):

  • core.pyBallTableCollisionStrategy protocol, CoreBallTableCollision
    ABC with make_kiss + resolve, and the helpers bounce_height and
    final_ball_motion_state. Ported verbatim from 3d except for the addition
    of dim: Dim on the protocol.
  • frictionless_inelastic/__init__.pyFrictionlessInelasticTable, the
    simple vz' = -e_t · vz model with a bounce-height cutoff to avoid the
    dichotomy paradox.
  • frictional_inelastic/__init__.pyFrictionalInelasticTable, the
    realistic frictional model (TP_A-14). Numba-jitted core with the slip /
    no-slip branch.
  • __init__.py — registry and re-exports.

Both strategies declare dim = Dim.THREE — they only ever fire in 3D mode.
The associated machinery (DORMANT_IN_2D) is described below.

New BallTableModel enum (pooltool/physics/resolve/models.py):

  • FRICTIONLESS_INELASTIC and FRICTIONAL_INELASTIC. (Note: the 3d branch had
    a typo — FRICTIONAL_ELASTIC for the frictionless variant — fixed here.)

e_t on BallParams (pooltool/objects/ball/params.py):

  • New field e_t: float = 0.5 (ball-table coefficient of restitution), matching
    3d's default. Inserted between e_b and e_c.

on_table predicate (pooltool/physics/utils.py):

  • on_table(rvw, R) -> bool returns rvw[0, 2] == R. Numba-jitted. Consumed
    by final_ball_motion_state to distinguish "settled on table" from "still
    airborne". Exact float equality is intentional — make_kiss explicitly sets
    rvw[0, 2] = R immediately before this predicate runs.

Resolver wiring (pooltool/physics/resolve/resolver.py):

  • New required field ball_table: BallTableCollisionStrategy.
  • BALL_TABLE dispatch branch in Resolver.resolve.
  • default_resolver() ships FrictionalInelasticTable(min_bounce_height=0.005).
  • VERSION bumped 9 → 10. Existing resolver.yaml files will fail structure
    (missing ball_table key) and be cleanly regenerated by the existing
    recovery path.

Serialize hooks (pooltool/physics/resolve/serialize.py):

  • BallTableCollisionStrategy: ball_table_models added to _model_map.

Re-exports (pooltool/physics/__init__.py):

  • BallTableModel, ball_table_models added to the public surface.

Dim validator: DORMANT_IN_2D exclusion
(pooltool/evolution/event_based/config.py, pooltool/evolution/engine.py):

  • New DORMANT_IN_2D: frozenset[str] = frozenset({"ball_table"})
    Resolver/EventDetector field names whose dim is not validated against the
    engine's is_3d when is_3d=False. These fields are dormant in 2D
    because the detection layer doesn't emit their associated event types.
  • _validate_dimensionality gains a single asymmetric skip:
    if not self.is_3d and field.name in DORMANT_IN_2D: continue. In 3D mode
    the field is still validated normally, so a misdeclared (e.g. Dim.TWO)
    ball-table strategy is still caught.
  • Colocated with INCLUDED_EVENTS because both answer "what does the
    event-based simulator engage with?"

Dim docstring refactor (pooltool/physics/dimensionality.py):

  • Reframed around safety rather than behavior equivalence. The old wording
    ("BOTH means the strategy behaves identically in either mode") was
    misleading — a Dim.BOTH strategy may still take different code paths
    depending on its input (e.g. a branch on state == const.airborne is dead
    in 2D and live in 3D). What matters is that no path is incorrect for the
    mode it runs under. Strategies don't see is_3d directly; they see only
    the inputs they're handed. The new docstring makes that explicit.

Why Dim.THREE + DORMANT_IN_2D for ball-table

Ball-table collisions only physically occur in 3D mode (no airborne state in
2D means no ball ever falls back to the table). The honest dim tag is
therefore Dim.THREE. But the Resolver dataclass requires a ball_table
field, which would force SimulationEngine(is_3d=False) to fail validation
on a Dim.THREE strategy.

Two ways out were considered:

  • Dim.BOTH tag. Sidesteps validation by declaring the strategy is safe
    in either mode. Works, but contrived: ball_table isn't actually "behaves
    the same in either mode"; it's "doesn't run in 2D mode." The Dim contract
    is the wrong level to encode that.
  • Dim.THREE tag + per-field exclusion in the validator. Keeps Dim
    honest and captures the "mode-conditional" fact at the right layer —
    in the validator, alongside the rest of the engine's mode logic.

We went with the second. The exclusion is asymmetric on purpose: in 2D the
slot is genuinely dormant and its tag is irrelevant; in 3D the tag matters
and is checked, catching Dim.TWO misdeclarations on what is fundamentally
a 3D-only strategy.

Tests added (62 cases across 3 files)

tests/physics/resolve/ball_table/test_ball_table.py (53 cases, 8 tests):

Pure helpers:

  • test_bounce_height (4 cases) — 0.5·vz²/g for various positive vz and
    g.
  • test_bounce_height_negative_vz — symmetry under vz → -vz.

Resolution invariants (parameterized over both models × 10 incoming speeds
spanning −logspace(−5, 4, 10)):

  • test_non_negative_output_velocity — outgoing vz ≥ 0 after resolve.
  • test_decaying_velocity — outgoing speed ≤ incoming speed (no e_t > 1).
  • test_positive_incoming_velocity_failsvz = +1 raises ValueError.
  • test_zero_incoming_velocity_failsvz = 0 raises ValueError.

Dichotomy-paradox cutoff:

  • test_non_airborne_outgoing_statevz = -0.001 (below min_bounce_height)
    → outgoing state is not airborne and vz is exactly 0. Validates the
    bounce-height floor that prevents perpetual bouncing.

tests/physics/resolve/ball_table/test_frictionless_inelastic.py (7 cases,
2 tests):

Targets the private scalar _resolve_ball_table(vz0, e_t) -> float to lock
in the exact arithmetic −vz·e_t, independent of the strategy wrapper.

tests/evolution/test_engine.py (2 new tests) — pin the asymmetric
DORMANT_IN_2D behavior:

  • test_dormant_ball_table_skipped_in_2d — a default 2D engine constructs
    successfully even though ball_table.dim == Dim.THREE. The validator
    skips the slot.
  • test_misdeclared_ball_table_still_caught_in_3d — flipping the ball_table
    strategy to Dim.TWO in a 3D engine raises ValueError naming ball_table.
    The exclusion is 2D-only.

Summary by CodeRabbit

  • New Features

    • Added ball-table collision resolution with frictionless and frictional inelastic interaction models
    • Introduced coefficient of restitution parameter for ball-table collisions
  • Improvements

    • Enhanced 2D/3D simulation validation to skip dimensionality checks for inactive field configurations
  • Tests

    • Added comprehensive test coverage for ball-table collision physics and behavior

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

Walkthrough

This PR introduces ball-table collision physics to the pooltool simulator. It adds a 2D-aware dimensionality validation framework, defines collision model types and core physics helpers, implements two collision strategies (frictionless and frictional inelastic), integrates collision handling into the event resolver, and provides comprehensive validation tests.

Changes

Ball-Table Collision System

Layer / File(s) Summary
Dimensionality Validation for Dormant Fields
pooltool/evolution/event_based/config.py, pooltool/evolution/engine.py, pooltool/physics/dimensionality.py, tests/evolution/test_engine.py
Introduces DORMANT_IN_2D constant marking ball_table as a field that skips is_3d validation in 2D mode. SimulationEngine._validate_dimensionality() checks this set and bypasses validation for dormant fields when is_3d is False. Documentation reframes dim as a safety contract rather than behavioral mode. Tests verify dormant fields pass validation in 2D and still validate correctly in 3D.
Ball Parameter Extension
pooltool/objects/ball/params.py
Adds e_t: float = 0.5 field to BallParams as the ball-table coefficient of restitution, with docstring documentation.
Ball-Table Model Type Definition
pooltool/physics/resolve/models.py, pooltool/physics/utils.py
Introduces BallTableModel enum with FRICTIONLESS_INELASTIC and FRICTIONAL_INELASTIC variants. Adds JIT-compiled on_table(rvw, R) helper to detect when ball is at table surface.
Core Ball-Table Collision Abstractions
pooltool/physics/resolve/ball_table/core.py
Defines physics helpers bounce_height(vz, g) and final_ball_motion_state(rvw, R) to compute bounce height and classify motion state (pocketed, airborne, sliding, rolling, spinning, stationary). Establishes protocol-based strategy interfaces and CoreBallTableCollision ABC that implements shared workflow: optional ball copy, "kiss" translation setting vertical position to radius, and delegation to abstract solve() for model-specific resolution.
Frictionless Inelastic Collision Strategy
pooltool/physics/resolve/ball_table/frictionless_inelastic/__init__.py, tests/physics/resolve/ball_table/test_frictionless_inelastic.py
Implements FrictionlessInelasticTable strategy with simple z-velocity restitution update (-vz0 * e_t), bounce-height clamping, and state finalization. Validates incoming z-velocity is negative; clamps outgoing velocity to zero for bounces below min_bounce_height threshold. Unit tests verify numeric correctness across parametrized inputs and error handling for invalid velocities.
Frictional Inelastic Collision Strategy
pooltool/physics/resolve/ball_table/frictional_inelastic/__init__.py
Implements FrictionalInelasticTable strategy with Numba-optimized impulse calculation supporting friction during table impact: computes normal impulse from restitution, derives surface-relative velocity, and branches between slip-based (when relative motion exists) or no-slip tangential impulse updates. Applies bounce-height threshold and state finalization.
Strategy Registry and Public API Export
pooltool/physics/resolve/ball_table/__init__.py, pooltool/physics/__init__.py
Constructs ball_table_models dictionary mapping BallTableModel enum values to strategy implementation classes via attrs.fields_dict() introspection. Exports base types and concrete strategies through __all__. Re-exports BallTableModel and ball_table_models at pooltool.physics public API level.
Resolver Integration and Serialization
pooltool/physics/resolve/resolver.py, pooltool/physics/resolve/serialize.py
Adds ball_table: BallTableCollisionStrategy field to Resolver initialized with FrictionalInelasticTable and configurable min_bounce_height. Updates Resolver.resolve() to handle EventType.BALL_TABLE by calling self.ball_table.resolve(...) and setting ball.state.t to event time. Bumps VERSION from 9 to 10. Extends serialization _model_map to handle ball-table strategy models via YAML/JSON hooks.
Comprehensive Ball-Table Physics Tests
tests/physics/resolve/ball_table/test_ball_table.py
Tests bounce-height computation across positive, zero, and negative velocities; validates both collision strategies produce non-negative outgoing z-velocity for log-spaced range of negative inputs; verifies velocity magnitude decay; ensures positive/zero incoming z-velocity raises ValueError with "can't collide with table surface" message; tests edge case where very small negative velocity results in exactly zero outgoing z-velocity and non-airborne state.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


Possibly related PRs

  • ekiefl/pooltool#294: Introduces the foundational 2D/3D dimensionality validation framework that this PR leverages with the dormant-field skip mechanism for ball_table.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add ball-table resolver strategies' directly and clearly describes the main feature introduced in this pull request: a new ball-table collision resolver with multiple strategy implementations (frictionless and frictional models).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ek/ball-table-resolver

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ekiefl
Copy link
Copy Markdown
Owner Author

ekiefl commented May 18, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 18, 2026

Codecov Report

❌ Patch coverage is 75.53957% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 47.81%. Comparing base (0d674f7) to head (02967db).

Files with missing lines Patch % Lines
...esolve/ball_table/frictional_inelastic/__init__.py 52.08% 23 Missing ⚠️
pooltool/physics/resolve/ball_table/core.py 84.61% 6 Missing ⚠️
pooltool/physics/resolve/resolver.py 42.85% 4 Missing ⚠️
pooltool/physics/utils.py 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #297      +/-   ##
==========================================
+ Coverage   47.45%   47.81%   +0.36%     
==========================================
  Files         153      157       +4     
  Lines       10493    10631     +138     
==========================================
+ Hits         4979     5083     +104     
- Misses       5514     5548      +34     
Flag Coverage Δ
service 47.81% <75.53%> (+0.36%) ⬆️
service-no-ani 58.34% <75.53%> (+0.36%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pooltool/physics/resolve/ball_table/core.py`:
- Around line 14-19: The function bounce_height currently divides by g without
validation; add an explicit guard in bounce_height to ensure g is positive (g >
0) and raise a clear ValueError (e.g., "gravity must be positive") when g is
zero or negative so the function cannot return non-physical results or crash
with a ZeroDivisionError.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d7ca49f9-0ed7-44d8-94de-a0da556e2334

📥 Commits

Reviewing files that changed from the base of the PR and between 0d674f7 and 02967db.

📒 Files selected for processing (17)
  • pooltool/evolution/engine.py
  • pooltool/evolution/event_based/config.py
  • pooltool/objects/ball/params.py
  • pooltool/physics/__init__.py
  • pooltool/physics/dimensionality.py
  • pooltool/physics/resolve/ball_table/__init__.py
  • pooltool/physics/resolve/ball_table/core.py
  • pooltool/physics/resolve/ball_table/frictional_inelastic/__init__.py
  • pooltool/physics/resolve/ball_table/frictionless_inelastic/__init__.py
  • pooltool/physics/resolve/models.py
  • pooltool/physics/resolve/resolver.py
  • pooltool/physics/resolve/serialize.py
  • pooltool/physics/utils.py
  • tests/evolution/test_engine.py
  • tests/physics/resolve/ball_table/__init__.py
  • tests/physics/resolve/ball_table/test_ball_table.py
  • tests/physics/resolve/ball_table/test_frictionless_inelastic.py

Comment on lines +14 to +19
def bounce_height(vz: float, g: float) -> float:
"""Return how high a ball with outgoing positive z-velocity will bounce.

Measured as distance from table to bottom of ball.
"""
return 0.5 * vz**2 / g
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard bounce_height against non-positive gravity.

bounce_height divides by g directly; g == 0 crashes and g < 0 yields non-physical output. Add an explicit validation guard.

Suggested fix
 def bounce_height(vz: float, g: float) -> float:
@@
-    return 0.5 * vz**2 / g
+    if g <= 0:
+        raise ValueError(f"g must be > 0, got {g}")
+    return 0.5 * vz**2 / g
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def bounce_height(vz: float, g: float) -> float:
"""Return how high a ball with outgoing positive z-velocity will bounce.
Measured as distance from table to bottom of ball.
"""
return 0.5 * vz**2 / g
def bounce_height(vz: float, g: float) -> float:
"""Return how high a ball with outgoing positive z-velocity will bounce.
Measured as distance from table to bottom of ball.
"""
if g <= 0:
raise ValueError(f"g must be > 0, got {g}")
return 0.5 * vz**2 / g
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pooltool/physics/resolve/ball_table/core.py` around lines 14 - 19, The
function bounce_height currently divides by g without validation; add an
explicit guard in bounce_height to ensure g is positive (g > 0) and raise a
clear ValueError (e.g., "gravity must be positive") when g is zero or negative
so the function cannot return non-physical results or crash with a
ZeroDivisionError.

@ekiefl ekiefl merged commit a18b4f8 into main May 18, 2026
12 checks passed
@ekiefl ekiefl deleted the ek/ball-table-resolver branch May 18, 2026 03:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant