diff --git a/tests/cli/test_serve_terminal.py b/tests/cli/test_serve_terminal.py index e4ebee55..0ee377cb 100644 --- a/tests/cli/test_serve_terminal.py +++ b/tests/cli/test_serve_terminal.py @@ -38,6 +38,9 @@ pytestmark = [needs_pty, pytest.mark.own_child_cache] HANG = 600 +#: How long, once the child has exited, the reader has to take what it wrote and see the terminal report its end: a guard +#: against a descriptor that something else holds open, not a bound on the reading (which ends with the terminal's end). +END_WITHIN = 30.0 @pytest.fixture(scope="module") @@ -77,7 +80,6 @@ def __init__(self, folder: Path, tmp: Path, *, terminals: tuple[str, ...], colum env=child_environment(home, **(env or {})), cwd=tmp, close_fds=True, start_new_session=True) os.close(slave) self._open = True - self._last = time.monotonic() self._reader = threading.Thread(target=self._read, daemon=True) self._reader.start() self.piped: tuple[str, str] | None = None @@ -107,7 +109,6 @@ def _read(self) -> None: return with self._lock: self._held += more - self._last = time.monotonic() @property def tty(self) -> str: @@ -133,8 +134,11 @@ def until(self, wanted, what: str) -> str: def finish(self, limit: float | None = None) -> tuple[str, str, int]: """Wait for the exit; what the pipes received (empty for a terminal) and the exit code.""" out, err = self.process.communicate(timeout=HANG if limit is None else limit) - while time.monotonic() - self._last < 0.5: # what the child wrote last is still on its way through the terminal - time.sleep(0.05) + # What the child wrote last is in the terminal, not yet in ``tty``, and a quiet spell does not say it has arrived (a + # reader that a loaded machine held up for longer than the spell lost the end: "the live region is gone"). The + # event is the terminal's own end, which the reader returns on once it has taken everything before it. A reader + # that was stopped on purpose (``stop_reading``) is already over, and this returns at once. + self._reader.join(timeout=END_WITHIN) self._open = False self._reader.join(timeout=2) return (out or b"").decode("utf-8"), (err or b"").decode("utf-8"), self.process.returncode diff --git a/tests/duration_allowlist.txt b/tests/duration_allowlist.txt index 4aa726a9..7bb92709 100644 --- a/tests/duration_allowlist.txt +++ b/tests/duration_allowlist.txt @@ -608,4 +608,5 @@ tests/docs/test_build_wiring.py::test_a_path_of_another_machine_in_a_page_stops_ tests/docs/test_build_wiring.py::test_the_passing_arm_a_relative_path_in_a_page_builds_and_checks # kept: the build-wiring check, which runs the real mime docs build and mime docs check over the checkout in a fresh process after planting an entry (it imports JAX, builds the pages and collects the test suite for the evidence stage), so that a guard dropped from its caller fails in the command and not only as a function; it took 15.7 to 16.7 s alone and 27 to 32 s in its shard on the maddening-main lane's runners (runs 41631822884 and 41635496576), which with the shard's other slow tests put six over 20 s, past the budget report's limit of five for forgiving a stall by re-running it tests/viz/test_recording_time_base.py::test_a_live_sessions_codes_over_the_rate_are_seconds_from_where_it_began_recording[4] # kept: a live session's codes over the rate count seconds from where it began recording, here for the fourth case; tracing-bound, not a cache: 21.4 s with 57 % in tracing and lowering (a program rebuilt per case) in default 3.12 shard 5 of run 41731349577; this file's tests also ran 20.4 and 20.5 s in openmaterica-main shard 5 of run 41674916914. To make faster: one traced session shared by the cases. tests/saved/test_reason_sites.py::test_a_condition_s_reason_is_saved_and_judged_after_a_load # kept: a condition's reason is saved and judged after a load, through a real build, save and load; 20.1 s (61 % running) in default 3.12 shard 5 of run 41731349577, and up to 25.8 s on cold shards in the durations analysis of 2026-11-03 (16 s warm). To make faster: a module fixture so the first compile is paid once. -tests/saved/test_duration_saved.py::test_the_control_a_folder_with_no_duration_still_needs_one # kept: the control that a saved folder declaring no run length is refused by `mime run` and runs once it is given one, without which the run-length tests could not fail: one save and two in-process `mime run`s, each loading and building the settling ball; under 10 s warm, and over the 20 s line on a cold compilation cache, where it fails v0.3's own push run at f7ab1c3 too +tests/saved/test_switch_harness.py::test_control_a_guard_no_point_reaches_false_or_a_nonzero_norm_at_zero_fails # kept: the control of the coverage check on the guards: it reaches every guard point of the script's graph in the test-only setting (each point traced afresh, with the module fixture's build of the graph in its setup) and then plants an unreached guard and a point at zero, which the check must name; without it the check that every guard is reached is not evidence. 1.8 min (50 % running) in default 3.14 shard 10 of v0.3 push run 41767802990, 1.7 min in run 41762639938, 1.6 min in run 41774881679; alone on a laptop with CI's cache settings it takes 68.8 s cold (31.1 s compiling, 16.0 s tracing, 3.1 s lowering) and 38.4 s warm (2.5 s compiling, 15.6 s tracing, 2.7 s lowering, the remaining 17 s its own running), so the time is tracing and its own work with a compile on top when cold, and a warm cache removes only the compile. To make faster (dev2): it reaches every guard point again, the same list that test_every_guard_the_scan_lists_is_reached_false_and_every_nonzero_norm_away_from_zero (also listed) reaches just before it; one module fixture that reaches them once would serve both and halve the two +tests/saved/test_duration_saved.py::test_the_control_a_folder_with_no_duration_still_needs_one # kept: the control of the saved run length: a folder saved with no duration is refused by mime run for it, and the same folder runs once given one, so that the two runs are the evidence that the length is what the folder declared; each of the two is a real build, compile and run of the settling ball. 28.8 s (88 % running) in default 3.12 shard 9 of v0.3 push run 41767802990; alone on a laptop it takes 11.0 s cold and 8.9 s warm (0.4 s compiling, 0.6 s tracing warm), with 77 to 85 CPU seconds in those 9 to 11 s of wall time, so the time is the runs' own multi-threaded compute (on a 4-core runner 77 CPU seconds alone are 19 s of wall time), which no cache removes diff --git a/tests/oracles/test_dynamic_arm.py b/tests/oracles/test_dynamic_arm.py index 1313467a..f914db2c 100644 --- a/tests/oracles/test_dynamic_arm.py +++ b/tests/oracles/test_dynamic_arm.py @@ -62,7 +62,7 @@ import multibody_cases as cases from mime.core import quaternion -from mime.core.multibody import gravity_forces, inverse_dynamics, mass_matrix, read_urdf +from mime.core.multibody import forward_dynamics, gravity_forces, inverse_dynamics, mass_matrix, read_urdf from mime.core.multibody._kinematics import body_poses, constants from mime.nodes.actuation import DynamicArmNode @@ -171,10 +171,12 @@ def gives_energy(self, p, q, free, inverse, dt): @st.composite -def runs(draw, name): +def runs(draw, name, at_a_stop: bool | None = None): """A start within the middle half of each joint's range, a speed of at least a tenth of ``SPEED`` on each joint, - so that the arm moves and gravity works on it, and torques of the joint's own scale; on half the draws, one joint - starts near a stop, turning into it and pushed into it, 10 to 50 % of the run's travel at its speed away.""" + so that the arm moves and gravity works on it, and torques of the joint's own scale. On half the draws one joint + starts near a stop, turning into it and pushed into it, 10 to 50 % of the run's travel at its speed away. + ``at_a_stop`` fixes that half: false for none, true for every draw, and then built so that the run reaches the stop + (see :func:`into_a_stop`) and the test that needs a stop to be reached throws no draw away.""" tree = TREES[name] a = tree.articulation lower, upper = np.maximum(a.lower, -math.pi), np.minimum(a.upper, math.pi) @@ -185,7 +187,11 @@ def runs(draw, name): speed = draw(unit) qd0 = SPEED * np.sign(speed + 0.5) * (0.1 + 0.9 * np.abs(speed)) tau = ACCELERATION * inertia * draw(unit) - if draw(st.booleans()): + if at_a_stop: + joint = draw(st.integers(0, a.dofs - 1)) + side = draw(st.sampled_from([-1.0, 1.0])) + into_a_stop(tree, q0, qd0, tau, joint, side, draw(st.floats(0.1, 0.5)), lower, upper) + elif at_a_stop is None and draw(st.booleans()): joint = draw(st.integers(0, a.dofs - 1)) side = draw(st.sampled_from([-1.0, 1.0])) q0[joint] = (upper if side > 0 else lower)[joint] - side * abs(qd0[joint]) * HORIZON * draw(st.floats(0.1, 0.5)) @@ -193,6 +199,27 @@ def runs(draw, name): return name, q0, qd0, tau +def into_a_stop(tree, q0, qd0, tau, joint: int, side: float, fraction: float, lower, upper) -> None: + """Turn ``joint`` toward the stop on ``side`` and push it there so that the run reaches it, in place: the speed along + ``side``, the torque raised until the joint's own acceleration at the start (``M^-1 (tau - damping qd - h)``, with the + weight and the others' coupling in ``h``, taken at the stop) is at least ``ACCELERATION`` toward the stop, and the + joint put ``fraction`` of the run's travel from it. A torque that only has the sign of the stop can be outweighed by + the weight or the coupling, and then the joint is carried away from it (a third to a half of such draws reached no + stop at all, so the test that needs one threw them away).""" + a = tree.articulation + stop = float((upper if side > 0 else lower)[joint]) + damping = np.asarray([tree.joint(n).damping for n in a.dof_names]) + q0[joint], qd0[joint] = stop, side * abs(qd0[joint]) + at_stop = jnp.asarray(q0) + qdd = np.asarray(forward_dynamics(a, at_stop, jnp.asarray(qd0), jnp.asarray(tau - damping * qd0), GRAVITY)) + towards = side * qdd[joint] + if towards < ACCELERATION: + compliance = float(np.linalg.inv(np.asarray(mass_matrix(a, at_stop)))[joint, joint]) + tau[joint] += side * (ACCELERATION - towards) / compliance + towards = ACCELERATION + q0[joint] = stop - side * (abs(qd0[joint]) * HORIZON + 0.5 * towards * HORIZON ** 2) * fraction + + class Found(NamedTuple): """What a run shows at the three step sizes: each step size's summed residual of the steps that do not end on a stop, the order each halving shows, whether a stop was reached, at the finest step size gravity's summed work, the @@ -275,7 +302,7 @@ def test_control_the_arm_with_its_gravity_dropped_fails_the_balance_on_every_dra @PROPERTY -@given(st.sampled_from(sorted(TREES)).flatmap(runs)) +@given(st.sampled_from(sorted(TREES)).flatmap(lambda name: runs(name, at_a_stop=True))) def test_control_a_stop_that_gives_energy_fails_the_balance_on_every_draw(drawn): """A run that reaches a stop at all three step sizes, with a residual the rounding does not hide (a gift of ``GIFT`` times the unplanted run's largest at any of its step sizes, well above ``ROUNDING`` units in the last place @@ -286,7 +313,8 @@ def test_control_a_stop_that_gives_energy_fails_the_balance_on_every_draw(drawn) only a residual above the integrator's own, could not see it: the plant below the oracle's resolution.""" name, q0, qd0, tau = drawn plain = orders(BALANCES[name], q0, qd0, tau) - assume(all(at_stops > -math.inf for at_stops in plain.stops) and plain.largest > 1e3 * ROUNDING * EPS * plain.scale) + assert all(at_stops > -math.inf for at_stops in plain.stops), f"{name}: the draw was built to reach a stop: {plain.stops}" + assert plain.largest > 1e3 * ROUNDING * EPS * plain.scale, f"{name}: a residual the rounding hides: {plain.largest}" planted = orders(GIVERS[name], q0, qd0, tau, GIFT * plain.peak) assert stops_not_falling(planted), f"{name}: at the stops, at each step size, {planted.stops}"