ripples-sci 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ripples/_test.py ADDED
@@ -0,0 +1,1504 @@
1
+ """
2
+ Installation-integrity tests for `ripples`, and the unified test dispatcher.
3
+
4
+ This script does not re-test the numerical machinery - the differentiation and
5
+ optimization suites own that. Its job is the layer below: to prove that the
6
+ package the user actually installed is whole and wired correctly. A wheel can
7
+ import cleanly and still be broken - a re-export pointing at the wrong object, a
8
+ metadata field dropped during packaging, a NumPy too old for the code that
9
+ relies on it, a submodule whose test script never made it into the
10
+ distribution. Every section here checks one such property, so that a green run
11
+ means "what landed on this machine is the library, intact" before any feature
12
+ test is trusted.
13
+
14
+ It exercises, in reading order:
15
+
16
+ - Package import and the public metadata (`__version__`, author, licence).
17
+ - The runtime environment against the documented floors (Python, NumPy).
18
+ - The public API surface, the `__all__` declaration, and the curated `__dir__`.
19
+ - Submodule reachability and the identity of every top-level re-export.
20
+ - The bundled per-submodule test scripts and the unified `test()` entry point.
21
+ - A minimal end-to-end smoke call of each public function on the installed
22
+ package - loose tolerances, since the point is "it runs and lands in the
23
+ right place", not "it is accurate to the last digit".
24
+
25
+
26
+ The dispatcher
27
+ --------------
28
+ `test()` is the single front door named in the README and in both submodule
29
+ suites. It routes to the installation checks here, to one submodule suite, or
30
+ to everything at once, folding the per-suite tallies into one combined summary
31
+ in the last case.
32
+
33
+
34
+ Output convention
35
+ -----------------
36
+ For each test the script prints, on its own block:
37
+
38
+ - a header line of the form:
39
+ "Section <n>, Test <m>: s<n>.<name> - <description>", where the description
40
+ states in one sentence what the test verifies and why it matters, with the
41
+ checking procedure folded into the same sentence.
42
+ - RESULT : the measured value(s).
43
+ - VERDICT : one of PASS / FAIL / INFO / SKIP, followed by an
44
+ interpretation of what the result means in practice.
45
+
46
+
47
+ How to run
48
+ ----------
49
+ Through the library's unified entry point::
50
+
51
+ >>> import ripples
52
+ >>> ripples.test("installation") # just these checks
53
+ >>> ripples.test("differentiation")
54
+ >>> ripples.test("optimization")
55
+ >>> ripples.test() # "all" - every suite, one summary
56
+
57
+ from the command line::
58
+
59
+ python -m ripples._test
60
+ # append --all to chain the submodule suites after the installation checks
61
+
62
+ or programmatically, when a caller wants the tallies back::
63
+
64
+ >>> from ripples._test import run
65
+ >>> reporter = run()
66
+ >>> reporter.failed
67
+ 0
68
+
69
+ Under continuous integration the suite is reached through pytest, which
70
+ collects the bridge test at the foot of this file. However it is launched,
71
+ every path funnels through the same `run()`, so the output and the verdicts are
72
+ identical no matter how the suite is started.
73
+
74
+ The exit code of the command-line form is 0 when every test passes and the
75
+ number of failures otherwise. The summary block at the end lists every
76
+ failure together with its test name, the expected value, and the actual value.
77
+ """
78
+
79
+ # Copyright (c) Álvaro Cátedra Sánchez <alvaro.catedra.sanchez@gmail.com>,
80
+ # unique author and maintainer.
81
+
82
+ # This module is licensed under the Apache License 2.0. You may use, modify,
83
+ # and distribute this software under the terms of the license, provided that
84
+ # proper attribution is given to the original author. See LICENSE.txt for
85
+ # full details.
86
+
87
+ import numpy as np
88
+
89
+ import re
90
+ import sys
91
+ import importlib
92
+ from typing import Callable, Dict, List, Tuple, Optional, Any, Literal
93
+
94
+
95
+
96
+ FLOAT_EPSILON = float(np.finfo(float).eps)
97
+
98
+ SEPARATOR_WIDTH = 79
99
+
100
+ _CURRENT_SECTION_TEST_NUMBER = 0
101
+
102
+
103
+ # Kept here as the one place the environment checks read so a bump in the
104
+ # requirements is a one-line edit rather than a hunt through the assertions.
105
+ MINIMUM_PYTHON_VERSION = (3, 10)
106
+ MINIMUM_NUMPY_VERSION = (1, 24)
107
+
108
+
109
+ # The public surface. Every name here must be reachable from `import ripples`,
110
+ # and nothing private should leak alongside it. It mirrors the `__all__`
111
+ # assembled in ripples/__init__.py, on purpose - the test exists precisely to
112
+ # catch the day the two drift apart.
113
+ EXPECTED_SUBMODULES = (
114
+ "differentiation",
115
+ "optimization",
116
+ )
117
+
118
+ EXPECTED_PUBLIC_FUNCTIONS = (
119
+ "nth_numerical_derivative",
120
+ "numerical_hessian_vector_product",
121
+ "DifferentiationResult",
122
+ "minimizer",
123
+ "OptimizationResult",
124
+ )
125
+
126
+ # The unified test entry point, re-exported at the top level from this module.
127
+ # It is part of the public surface - the README documents `ripples.test(...)` -
128
+ # so the import checks hold the package to its presence the same as any other
129
+ # advertised name.
130
+ EXPECTED_DISPATCHER = (
131
+ "test",
132
+ )
133
+
134
+ EXPECTED_METADATA = (
135
+ "__version__",
136
+ "__author__",
137
+ "__email__",
138
+ "__license__",
139
+ "__copyright__",
140
+ )
141
+
142
+
143
+
144
+ # REPORTING UTILITIES
145
+ #
146
+ # Every test routes through `check_*` helpers that record a pass / fail entry
147
+ # on the module-level `REPORT` object. The helpers print the verdict line in
148
+ # place, so that the caller can read the output top-to-bottom as the suite
149
+ # progresses, and the summary at the end of `run()` re-prints every failure.
150
+
151
+
152
+
153
+ class Reporter:
154
+ """
155
+ Aggregator for pass / fail / skip / info events.
156
+
157
+ A single instance is kept at module level (`REPORT`) so every test
158
+ can record into the same accumulator without explicit wiring.
159
+
160
+ Attributes
161
+ ----------
162
+ passed : int
163
+ Number of `check_*` calls that returned a passing verdict.
164
+ failed : int
165
+ Number of `check_*` calls that returned a failing verdict.
166
+ skipped : int
167
+ Number of tests that decided to short-circuit (for example,
168
+ because an optional part of the environment is absent).
169
+ info : int
170
+ Number of pure-information blocks.
171
+ failure_records : list of tuple
172
+ Tuples (test_name, message, expected, actual) recorded for
173
+ every failure, replayed verbatim in the final summary.
174
+ """
175
+
176
+ def __init__(self) -> None:
177
+ self.passed: int = 0
178
+ self.failed: int = 0
179
+ self.skipped: int = 0
180
+ self.info: int = 0
181
+ self.failure_records: List[Tuple[str, str, Any, Any]] = []
182
+
183
+ def reset(self) -> None:
184
+ """
185
+ Clear every counter and the recorded failure list.
186
+
187
+ Called at the top of `run()` so the same module-level reporter
188
+ can be reused across repeated invocations - for instance when
189
+ `ripples.test()` drives this suite on its own and then again as
190
+ part of an "all" run - without the second run inheriting the
191
+ tallies of the first.
192
+ """
193
+ self.passed = 0
194
+ self.failed = 0
195
+ self.skipped = 0
196
+ self.info = 0
197
+ self.failure_records.clear()
198
+
199
+ def record_pass(self, test_name: str, message: str) -> None:
200
+ self.passed += 1
201
+ print(f" VERDICT : PASS - {message}")
202
+
203
+ def record_fail(
204
+ self, test_name: str, message: str,
205
+ expected: Any = None, actual: Any = None,
206
+ ) -> None:
207
+ self.failed += 1
208
+ self.failure_records.append((test_name, message, expected, actual))
209
+ print(f" VERDICT : FAIL - {message}")
210
+ if expected is not None or actual is not None:
211
+ print(f" expected = {expected!r}")
212
+ print(f" actual = {actual!r}")
213
+
214
+ def record_skip(self, test_name: str, reason: str) -> None:
215
+ self.skipped += 1
216
+ print(f" VERDICT : SKIP - {reason}")
217
+
218
+ def record_info(self, message: str) -> None:
219
+ self.info += 1
220
+ print(f" VERDICT : INFO - {message}")
221
+
222
+
223
+ def print_summary(self) -> None:
224
+ """Print the end-of-run accounting and replay each failure."""
225
+
226
+ total_tests = self.passed + self.failed + self.skipped
227
+ print()
228
+ print("=" * SEPARATOR_WIDTH)
229
+ print(" FINAL SUMMARY ".center(SEPARATOR_WIDTH, "="))
230
+ print("=" * SEPARATOR_WIDTH)
231
+ print(f" Total tests : {total_tests}")
232
+ print(f" Passed : {self.passed}")
233
+ print(f" Failed : {self.failed}")
234
+ print(f" Skipped : {self.skipped}")
235
+ print(f" Info / benchmark : {self.info}")
236
+
237
+ if self.failure_records:
238
+ print()
239
+ print(" FAILURES ".center(SEPARATOR_WIDTH, "-"))
240
+ for test_name, message, expected, actual in self.failure_records:
241
+ print()
242
+ print(f" [{test_name}]")
243
+ print(f" {message}")
244
+ if expected is not None or actual is not None:
245
+ print(f" expected = {expected!r}")
246
+ print(f" actual = {actual!r}")
247
+ print("=" * SEPARATOR_WIDTH)
248
+
249
+
250
+
251
+ REPORT = Reporter()
252
+
253
+
254
+
255
+ def section_banner(section_title: str) -> None:
256
+ """
257
+ Open a new test section.
258
+
259
+ Prints a blank line, the section title as a plain comment line, and a
260
+ full-width rule of "#" beneath it, then resets the per-section test
261
+ counter so the next `test_block` starts again at "Test 1".
262
+ """
263
+ global _CURRENT_SECTION_TEST_NUMBER
264
+ _CURRENT_SECTION_TEST_NUMBER = 0
265
+ print()
266
+ print(f"# {section_title} ")
267
+ print("#" * SEPARATOR_WIDTH)
268
+
269
+
270
+
271
+ def test_block(test_name: str, description: str) -> None:
272
+ """
273
+ Print the one-line header that opens a single test.
274
+
275
+ The header reads::
276
+
277
+ Section <n>, Test <m>: s<n>.<rest> - <description>
278
+
279
+ where <n> is the section number (the section letter prefixing
280
+ `test_name`, mapped A -> 1 ... F -> 6), <m> is the position of this
281
+ test within the current section (reset by `section_banner`), and <rest>
282
+ is whatever follows the section letter. The VERDICT line is appended
283
+ afterwards by whichever `check_*` helper the test calls.
284
+
285
+ Parameters
286
+ ----------
287
+ test_name : str
288
+ The dotted identifier, beginning with its section letter, e.g.
289
+ "C.surface.public_functions_present". The same string is handed to
290
+ the `check_*` helpers, so a failure in the summary traces back here.
291
+ description : str
292
+ A single sentence stating what the test verifies and why it
293
+ matters, with the checking procedure folded in.
294
+ """
295
+ global _CURRENT_SECTION_TEST_NUMBER
296
+
297
+ section_letter, _, name_remainder = test_name.partition(".")
298
+ section_number = ord(section_letter.upper()) - ord("A") + 1
299
+ _CURRENT_SECTION_TEST_NUMBER += 1
300
+ numbered_name = f"s{section_number}.{name_remainder}"
301
+
302
+ print()
303
+ print("-" * SEPARATOR_WIDTH)
304
+ print(
305
+ f"Section {section_number}, Test {_CURRENT_SECTION_TEST_NUMBER}: "
306
+ f"{numbered_name} - {description}"
307
+ )
308
+
309
+
310
+
311
+ def _format_for_result_line(value: Any) -> str:
312
+ """Compact textual representation tailored for the RESULT line."""
313
+ if isinstance(value, np.ndarray):
314
+ with np.printoptions(precision=6, suppress=False, linewidth=200):
315
+ return np.array2string(value)
316
+ if isinstance(value, float):
317
+ return f"{value:.6e}"
318
+ return repr(value)
319
+
320
+
321
+
322
+ def check_allclose(
323
+ test_name: str,
324
+ actual_value: Any,
325
+ expected_value: Any,
326
+ relative_tolerance: float,
327
+ absolute_tolerance: float = 0.0,
328
+ interpretation: str = "",
329
+ ) -> bool:
330
+ """
331
+ Floating-point equality check.
332
+
333
+ `relative_tolerance` and `absolute_tolerance` are the same parameters
334
+ that numpy.allclose accepts. The pair is reported on the RESULT line
335
+ so the caller can re-run the test with a different tolerance band
336
+ without inspecting the source.
337
+
338
+ Returns True on pass, False on fail (so the caller may use the
339
+ return value to gate follow-up assertions).
340
+ """
341
+
342
+ actual_array = np.asarray(actual_value, dtype=float)
343
+ expected_array = np.asarray(expected_value, dtype=float)
344
+
345
+ # Element-wise absolute difference normalised by max(|expected|, eps).
346
+ denominator = np.maximum(np.abs(expected_array), FLOAT_EPSILON)
347
+ relative_error_per_entry = (
348
+ np.abs(actual_array - expected_array) / denominator
349
+ )
350
+ max_relative_error = float(np.max(relative_error_per_entry))
351
+
352
+ print(f" RESULT : actual = {_format_for_result_line(actual_value)}")
353
+ print(f" expected = "
354
+ f"{_format_for_result_line(expected_value)}")
355
+ print(f" max relative error = {max_relative_error:.6e}")
356
+ print(f" tolerance band "
357
+ f"= rtol={relative_tolerance:.1e}, "
358
+ f"atol={absolute_tolerance:.1e}")
359
+
360
+ is_close = bool(np.allclose(
361
+ actual_array, expected_array,
362
+ rtol=relative_tolerance, atol=absolute_tolerance,
363
+ equal_nan=False,
364
+ ))
365
+
366
+ if is_close:
367
+ message = interpretation or (
368
+ "actual value matches the expected one within the tolerance band."
369
+ )
370
+ REPORT.record_pass(test_name, message)
371
+ else:
372
+ REPORT.record_fail(
373
+ test_name,
374
+ interpretation or "value disagrees beyond the tolerance band.",
375
+ expected=expected_value, actual=actual_value,
376
+ )
377
+ return is_close
378
+
379
+
380
+
381
+ def check_truth(
382
+ test_name: str,
383
+ condition: bool,
384
+ message_on_pass: str,
385
+ message_on_fail: str,
386
+ ) -> bool:
387
+ """Plain boolean assertion. Used for presence / identity / flag checks."""
388
+
389
+ print(f" RESULT : condition evaluated to {bool(condition)}")
390
+ if condition:
391
+ REPORT.record_pass(test_name, message_on_pass)
392
+ return True
393
+
394
+ REPORT.record_fail(
395
+ test_name, message_on_fail, expected=True, actual=bool(condition)
396
+ )
397
+ return False
398
+
399
+
400
+
401
+ def check_raises(
402
+ test_name: str,
403
+ callable_object: Callable[[], Any],
404
+ expected_exception_type: type,
405
+ interpretation: str = "",
406
+ ) -> bool:
407
+ """
408
+ Assertion that calling `callable_object()` raises the given
409
+ exception type. Both "no exception" and "wrong exception type" count
410
+ as failures.
411
+ """
412
+
413
+ try:
414
+ callable_object()
415
+
416
+ except expected_exception_type as raised_exception:
417
+ print(f" RESULT : raised {type(raised_exception).__name__}: "
418
+ f"{raised_exception}")
419
+ REPORT.record_pass(
420
+ test_name,
421
+ interpretation
422
+ or f"call raised {expected_exception_type.__name__} as expected.",
423
+ )
424
+ return True
425
+
426
+ except BaseException as wrong_exception:
427
+ REPORT.record_fail(
428
+ test_name,
429
+ f"call raised {type(wrong_exception).__name__} instead of "
430
+ f"{expected_exception_type.__name__}.",
431
+ expected=expected_exception_type.__name__,
432
+ actual=type(wrong_exception).__name__,
433
+ )
434
+ return False
435
+
436
+ REPORT.record_fail(
437
+ test_name,
438
+ f"call returned normally; expected {expected_exception_type.__name__}.",
439
+ expected=expected_exception_type.__name__,
440
+ actual="<no exception>",
441
+ )
442
+ return False
443
+
444
+
445
+
446
+ # ENVIRONMENT PROBES
447
+ #
448
+ # Two tiny parsers shared by the environment section. Both read the leading
449
+ # numeric release segment of a version - "1.24.3rc1" -> (1, 24, 3) - and
450
+ # ignore any pre-release or local suffix, since the requirement floors are
451
+ # expressed only in terms of the release numbers.
452
+
453
+
454
+
455
+ def _release_tuple(version_string: str) -> Tuple[int, ...]:
456
+ """Parse the leading numeric release segment of a version into ints."""
457
+ leading_match = re.match(r"\d+(?:\.\d+)*", version_string.strip())
458
+ if leading_match is None:
459
+ return ()
460
+ return tuple(int(part) for part in leading_match.group(0).split("."))
461
+
462
+
463
+ def _format_version(version_tuple: Tuple[int, ...]) -> str:
464
+ """Render a release tuple back into a dotted string for the RESULT line."""
465
+ return ".".join(str(component) for component in version_tuple)
466
+
467
+
468
+
469
+ # SECTION A - PACKAGE IMPORT AND METADATA
470
+ #
471
+ # The first thing any install has to do is import. The second is identify
472
+ # itself: the version and the authorship/licence fields are what PyPI, pip, and
473
+ # any downstream tooling read, and a packaging slip that drops one of them is
474
+ # invisible until something asks for it. This section imports the package and
475
+ # confirms every metadata field is present, a non-empty string, and - for the
476
+ # version - shaped like a release number.
477
+
478
+
479
+
480
+ def section_a_import_and_metadata() -> None:
481
+ """
482
+ Import `ripples`, then confirm `__version__` parses as a dotted release
483
+ number and that the author, e-mail, licence, and copyright fields are all
484
+ present non-empty strings.
485
+ """
486
+
487
+ section_banner("SECTION A - Package import and metadata")
488
+
489
+ # the import itself
490
+ test_block(
491
+ test_name="A.import.package",
492
+ description=(
493
+ "verify `import ripples` succeeds, importing the top-level package "
494
+ "and checking the module object comes back; nothing else in the "
495
+ "suite can run if the package will not load, so this is the gate "
496
+ "every later test depends on."
497
+ ),
498
+ )
499
+ import ripples
500
+ check_truth(
501
+ test_name="A.import.package",
502
+ condition=ripples is not None,
503
+ message_on_pass="the top-level package imported.",
504
+ message_on_fail="the import produced no module object.",
505
+ )
506
+
507
+ # version is a release-shaped string
508
+ test_block(
509
+ test_name="A.metadata.version_is_release_shaped",
510
+ description=(
511
+ "verify `__version__` is a non-empty string whose leading segment "
512
+ "parses as a dotted release number, reading the attribute and "
513
+ "matching it against the release pattern; pip and PyPI reject or "
514
+ "mis-sort a version that is not PEP 440 shaped, so a malformed one "
515
+ "is a release blocker."
516
+ ),
517
+ )
518
+ version_string = getattr(ripples, "__version__", None)
519
+ version_is_release_shaped = (
520
+ isinstance(version_string, str)
521
+ and re.match(r"^\d+\.\d+", version_string.strip()) is not None
522
+ )
523
+ check_truth(
524
+ test_name="A.metadata.version_is_release_shaped",
525
+ condition=version_is_release_shaped,
526
+ message_on_pass=f"__version__ is {version_string!r}.",
527
+ message_on_fail=(
528
+ f"__version__ is missing or not release-shaped: "
529
+ f"{version_string!r}."
530
+ ),
531
+ )
532
+
533
+ # the remaining metadata fields
534
+ # Derived from EXPECTED_METADATA minus __version__ (handled separately
535
+ # above with its own release-shape check), so adding a new metadata field
536
+ # to EXPECTED_METADATA is enough; the loop below picks it up on its own.
537
+ descriptive_metadata = tuple(
538
+ metadata_name for metadata_name in EXPECTED_METADATA
539
+ if metadata_name != "__version__"
540
+ )
541
+ for metadata_name in descriptive_metadata:
542
+ test_block(
543
+ test_name=f"A.metadata.{metadata_name.strip('_')}",
544
+ description=(
545
+ f"verify `{metadata_name}` is present as a non-empty string, "
546
+ f"reading the attribute off the package and checking its type "
547
+ f"and length; this field is surfaced on PyPI and in tooling, "
548
+ f"and an empty or missing one is a packaging defect that "
549
+ f"import alone would never reveal."
550
+ ),
551
+ )
552
+ metadata_value = getattr(ripples, metadata_name, None)
553
+ check_truth(
554
+ test_name=f"A.metadata.{metadata_name.strip('_')}",
555
+ condition=(
556
+ isinstance(metadata_value, str)
557
+ and len(metadata_value) > 0
558
+ ),
559
+ message_on_pass=f"{metadata_name} = {metadata_value!r}",
560
+ message_on_fail=f"{metadata_name} is missing or empty.",
561
+ )
562
+
563
+
564
+
565
+ # SECTION B - RUNTIME ENVIRONMENT
566
+ #
567
+ # The code is written against documented floors - Python 3.10 for the syntax
568
+ # it uses, NumPy 1.24 for the array API it relies on. pip enforces these at
569
+ # install time from the metadata, but an editable install, a hand-built
570
+ # environment, or a stale wheel can sidestep that. Checking the live
571
+ # interpreter and the imported NumPy here turns a confusing downstream failure
572
+ # ("some attribute does not exist") into a clear up-front verdict.
573
+
574
+
575
+
576
+ def section_b_runtime_environment() -> None:
577
+ """
578
+ Compare the running Python and the imported NumPy against the documented
579
+ minimums, reporting both the found and the required version on each block.
580
+ """
581
+
582
+ section_banner("SECTION B - Runtime environment")
583
+
584
+ # Python
585
+ test_block(
586
+ test_name="B.environment.python_version",
587
+ description=(
588
+ "verify the running interpreter meets the documented Python floor, "
589
+ "comparing sys.version_info against the required minimum; the code "
590
+ "uses syntax introduced in that release, so an older interpreter "
591
+ "fails in ways that have nothing to do with the library's logic."
592
+ ),
593
+ )
594
+ running_python_version = sys.version_info[: len(MINIMUM_PYTHON_VERSION)]
595
+ print(f" RESULT : found Python "
596
+ f"{_format_version(tuple(running_python_version))}, "
597
+ f"require >= {_format_version(MINIMUM_PYTHON_VERSION)}")
598
+ check_truth(
599
+ test_name="B.environment.python_version",
600
+ condition=tuple(running_python_version) >= MINIMUM_PYTHON_VERSION,
601
+ message_on_pass="interpreter satisfies the documented floor.",
602
+ message_on_fail="interpreter is older than the documented floor.",
603
+ )
604
+
605
+ # NumPy
606
+ test_block(
607
+ test_name="B.environment.numpy_version",
608
+ description=(
609
+ "verify the imported NumPy meets the documented floor, parsing "
610
+ "numpy.__version__ and comparing against the required minimum; the "
611
+ "whole library is built on NumPy, so a version below the floor is "
612
+ "the most likely source of a subtle array-API mismatch."
613
+ ),
614
+ )
615
+ installed_numpy_version = _release_tuple(np.__version__)
616
+ comparable_numpy_version = installed_numpy_version[
617
+ : len(MINIMUM_NUMPY_VERSION)
618
+ ]
619
+ print(f" RESULT : found NumPy {np.__version__}, "
620
+ f"require >= {_format_version(MINIMUM_NUMPY_VERSION)}")
621
+ check_truth(
622
+ test_name="B.environment.numpy_version",
623
+ condition=comparable_numpy_version >= MINIMUM_NUMPY_VERSION,
624
+ message_on_pass="NumPy satisfies the documented floor.",
625
+ message_on_fail="NumPy is older than the documented floor.",
626
+ )
627
+
628
+
629
+
630
+ # SECTION C - PUBLIC API SURFACE
631
+ #
632
+ # The promise of the package is that `import ripples` is enough to reach every
633
+ # feature. That promise is held together by three things: the names being
634
+ # present, the `__all__` declaration agreeing with what is actually exported,
635
+ # and the curated `__dir__` showing the user the public surface and nothing
636
+ # else. A break in any of them is silent - the code still works, but the
637
+ # advertised interface no longer matches reality.
638
+
639
+
640
+
641
+ def section_c_public_api_surface() -> None:
642
+ """
643
+ Confirm every documented submodule, public function, and metadata name is
644
+ reachable from the package; that `__all__` is the union of the three and
645
+ contains no dangling name; and that `dir(ripples)` returns exactly the
646
+ sorted `__all__`.
647
+ """
648
+
649
+ section_banner("SECTION C - Public API surface")
650
+
651
+ import ripples
652
+
653
+ # every advertised name is present
654
+ expected_public_names = (
655
+ EXPECTED_SUBMODULES + EXPECTED_PUBLIC_FUNCTIONS
656
+ + EXPECTED_DISPATCHER + EXPECTED_METADATA
657
+ )
658
+ for public_name in expected_public_names:
659
+ test_block(
660
+ test_name=f"C.present.{public_name.strip('_')}",
661
+ description=(
662
+ f"verify `ripples.{public_name}` resolves, looking the name up "
663
+ f"on the package; this is one entry of the advertised surface, "
664
+ f"and a missing one means the documented one-import workflow "
665
+ f"is broken for that feature."
666
+ ),
667
+ )
668
+ check_truth(
669
+ test_name=f"C.present.{public_name.strip('_')}",
670
+ condition=hasattr(ripples, public_name),
671
+ message_on_pass=f"ripples.{public_name} is reachable.",
672
+ message_on_fail=f"ripples.{public_name} is absent.",
673
+ )
674
+
675
+ # the documented callables / classes are actually callable
676
+ for callable_name in EXPECTED_PUBLIC_FUNCTIONS + EXPECTED_DISPATCHER:
677
+ test_block(
678
+ test_name=f"C.callable.{callable_name}",
679
+ description=(
680
+ f"verify `ripples.{callable_name}` is callable, fetching the "
681
+ f"object and checking callable(); a name that resolves to a "
682
+ f"non-callable (a stray constant, a half-finished re-export) "
683
+ f"would pass the presence check yet fail the moment a user "
684
+ f"tried to use it."
685
+ ),
686
+ )
687
+ candidate = getattr(ripples, callable_name, None)
688
+ check_truth(
689
+ test_name=f"C.callable.{callable_name}",
690
+ condition=callable(candidate),
691
+ message_on_pass=f"{callable_name} is callable.",
692
+ message_on_fail=f"{callable_name} resolved to a non-callable.",
693
+ )
694
+
695
+ # __all__ is well-formed and complete
696
+ test_block(
697
+ test_name="C.dunder_all.union",
698
+ description=(
699
+ "verify `__all__` is a list holding exactly the union of the "
700
+ "documented submodules, public functions, and metadata, comparing "
701
+ "the two as sets; `__all__` is what `from ripples import *` "
702
+ "honours, so a name absent here is invisible to that idiom even "
703
+ "when it exists on the package."
704
+ ),
705
+ )
706
+ package_all = getattr(ripples, "__all__", None)
707
+ all_matches_expected = (
708
+ isinstance(package_all, list)
709
+ and set(package_all) == set(expected_public_names)
710
+ )
711
+ print(f" RESULT : __all__ has {len(package_all or [])} entries; "
712
+ f"expected {len(expected_public_names)}")
713
+ check_truth(
714
+ test_name="C.dunder_all.union",
715
+ condition=all_matches_expected,
716
+ message_on_pass="__all__ is exactly the documented public surface.",
717
+ message_on_fail="__all__ diverges from the documented public surface.",
718
+ )
719
+
720
+ # no name in __all__ dangles
721
+ test_block(
722
+ test_name="C.dunder_all.no_dangling_name",
723
+ description=(
724
+ "verify every name listed in `__all__` actually resolves on the "
725
+ "package, looking each one up; a name advertised but not bound "
726
+ "raises ImportError under `from ripples import *`, the kind of "
727
+ "fault that survives a clean import and only bites the star-import "
728
+ "user."
729
+ ),
730
+ )
731
+ dangling_names = [
732
+ name for name in (package_all or []) if not hasattr(ripples, name)
733
+ ]
734
+ check_truth(
735
+ test_name="C.dunder_all.no_dangling_name",
736
+ condition=len(dangling_names) == 0,
737
+ message_on_pass="every __all__ entry is bound on the package.",
738
+ message_on_fail=f"__all__ lists unbound names: {dangling_names}.",
739
+ )
740
+
741
+ # __dir__ is the sorted __all__
742
+ test_block(
743
+ test_name="C.dunder_dir.equals_sorted_all",
744
+ description=(
745
+ "verify `dir(ripples)` equals the sorted `__all__`, calling dir() "
746
+ "and comparing; the package defines a custom __dir__ so that tab-"
747
+ "completion shows the public surface and nothing private, and a "
748
+ "regression there quietly re-clutters the listing the user sees."
749
+ ),
750
+ )
751
+ directory_listing = dir(ripples)
752
+ check_truth(
753
+ test_name="C.dunder_dir.equals_sorted_all",
754
+ condition=directory_listing == sorted(package_all or []),
755
+ message_on_pass="dir(ripples) is the curated, sorted public surface.",
756
+ message_on_fail="dir(ripples) no longer matches sorted(__all__).",
757
+ )
758
+
759
+
760
+
761
+ # SECTION D - SUBMODULE REACHABILITY AND RE-EXPORT IDENTITY
762
+ #
763
+ # Each public function is defined deep inside a submodule and re-exported at
764
+ # the top level. The re-export is plumbing, and plumbing leaks: a refactor can
765
+ # leave `ripples.minimizer` pointing at a stale object while
766
+ # `ripples.optimization.minimizer` points at the live one, and both still
767
+ # import. The identity check (`is`) is the only thing that catches that - two
768
+ # different objects with the same name behave identically right up until they
769
+ # don't.
770
+
771
+
772
+
773
+ def section_d_submodule_reexport_identity() -> None:
774
+ """
775
+ Import each submodule directly, confirm it carries its own `__all__`, and
776
+ confirm every top-level public function is the very same object as the
777
+ attribute it was re-exported from.
778
+ """
779
+
780
+ section_banner("SECTION D - Submodule reachability and re-export identity")
781
+
782
+ import ripples
783
+
784
+ # each submodule imports on its own-
785
+ # The submodule objects are stashed in a dict here so the identity loop
786
+ # below can iterate them without re-importing - one import per submodule
787
+ # is enough.
788
+ imported_submodule_objects: Dict[str, Any] = {}
789
+ for submodule_name in EXPECTED_SUBMODULES:
790
+ test_block(
791
+ test_name=f"D.import.{submodule_name}",
792
+ description=(
793
+ f"verify `ripples.{submodule_name}` imports as a submodule, "
794
+ f"importing it directly and checking it exposes its own "
795
+ f"`__all__`; the top-level re-exports lean on the submodule "
796
+ f"loading cleanly, so a fault here explains a whole column of "
797
+ f"later failures at once."
798
+ ),
799
+ )
800
+ imported_submodule_objects[submodule_name] = importlib.import_module(
801
+ f"ripples.{submodule_name}"
802
+ )
803
+ check_truth(
804
+ test_name=f"D.import.{submodule_name}",
805
+ condition=hasattr(
806
+ imported_submodule_objects[submodule_name], "__all__"
807
+ ),
808
+ message_on_pass=(
809
+ f"ripples.{submodule_name} imported and declares __all__."
810
+ ),
811
+ message_on_fail=(
812
+ f"ripples.{submodule_name} imported without an __all__."
813
+ ),
814
+ )
815
+
816
+ # top-level re-exports are the same objects
817
+ #
818
+ # The library's stated invariant is that every public entry point reaches
819
+ # the top level, so what is in a submodule's `__all__` must be re-exported
820
+ # at `ripples.<name>` and must be the same object. The loop discovers the
821
+ # names by reading each submodule's `__all__` rather than hardcoding a
822
+ # function-to-submodule map; a new public name added to a submodule and
823
+ # re-exported needs no edit here, and a name added to the submodule but
824
+ # forgotten at the top level fails this check rather than slipping through.
825
+ for submodule_name, submodule_object in imported_submodule_objects.items():
826
+ submodule_public_names = getattr(submodule_object, "__all__", None)
827
+ if not submodule_public_names:
828
+ # Already reported as a failure by the import block above; nothing
829
+ # useful to iterate on, so skip the identity checks for this one.
830
+ continue
831
+
832
+ for public_name in submodule_public_names:
833
+ test_block(
834
+ test_name=f"D.identity.{public_name}",
835
+ description=(
836
+ f"verify `ripples.{public_name}` is the very object "
837
+ f"exported by ripples.{submodule_name}, comparing the two "
838
+ f"with `is`; a presence check passes even when the top "
839
+ f"level points at a stale copy, so identity is what "
840
+ f"actually proves the re-export wiring is live."
841
+ ),
842
+ )
843
+ top_level_object = getattr(ripples, public_name, None)
844
+ origin_object = getattr(submodule_object, public_name, None)
845
+ check_truth(
846
+ test_name=f"D.identity.{public_name}",
847
+ condition=top_level_object is origin_object
848
+ and top_level_object is not None,
849
+ message_on_pass=(
850
+ f"ripples.{public_name} is ripples.{submodule_name}."
851
+ f"{public_name}."
852
+ ),
853
+ message_on_fail=(
854
+ f"ripples.{public_name} is a different object from "
855
+ f"ripples.{submodule_name}.{public_name} (or one of "
856
+ f"them is missing)."
857
+ ),
858
+ )
859
+
860
+ # a phantom submodule does not resolve
861
+ test_block(
862
+ test_name="D.no_phantom_submodule",
863
+ description=(
864
+ "verify importing a submodule that does not exist raises "
865
+ "ModuleNotFoundError, attempting `ripples.does_not_exist` and "
866
+ "catching; this guards against an over-eager package hook that "
867
+ "would answer for names the library never defined and so mask a "
868
+ "caller's typo."
869
+ ),
870
+ )
871
+ check_raises(
872
+ test_name="D.no_phantom_submodule",
873
+ callable_object=lambda: importlib.import_module(
874
+ "ripples.does_not_exist"
875
+ ),
876
+ expected_exception_type=ModuleNotFoundError,
877
+ )
878
+
879
+
880
+
881
+ # SECTION E - BUNDLED TEST SUITES AND THE UNIFIED ENTRY POINT
882
+ #
883
+ # The per-submodule test scripts are shipped inside the wheel (the MANIFEST and
884
+ # the setuptools config pull every .py under the package in), and the unified
885
+ # `test()` dispatcher in this module is the front door the README points the
886
+ # user at. This section proves both are reachable and correctly shaped, without
887
+ # running them - executing the full submodule suites is what `test("all")`
888
+ # does, and duplicating that here would only slow the installation gate down.
889
+
890
+
891
+
892
+ def section_e_test_suite_wiring() -> None:
893
+ """
894
+ Confirm each submodule ships a `_test` module exposing the `run` callable
895
+ the dispatcher drives, that this module's own `test` dispatcher is callable,
896
+ rejects an unknown target with ValueError and a non-string target with
897
+ TypeError, and is the very object exposed as `ripples.test`.
898
+ """
899
+
900
+ section_banner(
901
+ "SECTION E - Bundled test suites and the unified entry point"
902
+ )
903
+
904
+ import ripples
905
+
906
+ # each submodule's _test ships and exposes run()
907
+ for submodule_name in EXPECTED_SUBMODULES:
908
+ test_block(
909
+ test_name=f"E.suite.{submodule_name}_run_callable",
910
+ description=(
911
+ f"verify ripples.{submodule_name}._test ships and exposes a "
912
+ f"callable `run`, importing the test module and checking the "
913
+ f"attribute; the unified dispatcher drives each suite through "
914
+ f"this exact entry point, so a suite missing from the wheel "
915
+ f"breaks `test(\"all\")` even though the library itself works."
916
+ ),
917
+ )
918
+ submodule_test = importlib.import_module(
919
+ f"ripples.{submodule_name}._test"
920
+ )
921
+ check_truth(
922
+ test_name=f"E.suite.{submodule_name}_run_callable",
923
+ condition=callable(getattr(submodule_test, "run", None)),
924
+ message_on_pass=(
925
+ f"ripples.{submodule_name}._test.run is present and callable."
926
+ ),
927
+ message_on_fail=(
928
+ f"ripples.{submodule_name}._test.run is missing or "
929
+ f"not callable."
930
+ ),
931
+ )
932
+
933
+ # the dispatcher in this module is callable
934
+ test_block(
935
+ test_name="E.dispatcher.callable",
936
+ description=(
937
+ "verify the `test` dispatcher defined in this module is callable, "
938
+ "checking the local object; it is the single front door every "
939
+ "other launch path funnels through, so it has to be a working "
940
+ "callable before any of the documented `ripples.test(...)` forms "
941
+ "can mean anything."
942
+ ),
943
+ )
944
+ check_truth(
945
+ test_name="E.dispatcher.callable",
946
+ condition=callable(test),
947
+ message_on_pass="the unified test() dispatcher is callable.",
948
+ message_on_fail="the unified test() dispatcher is not callable.",
949
+ )
950
+
951
+ # the dispatcher rejects an unknown target
952
+ test_block(
953
+ test_name="E.dispatcher.rejects_unknown_target",
954
+ description=(
955
+ "verify `test(\"non-existant\")` raises ValueError, calling the "
956
+ "dispatcher with a target it does not recognise and catching; a "
957
+ "silent no-op on a mistyped target would let a caller believe a "
958
+ "suite ran when nothing did, the worst failure mode a test runner "
959
+ "can have."
960
+ ),
961
+ )
962
+ check_raises(
963
+ test_name="E.dispatcher.rejects_unknown_target",
964
+ callable_object=lambda: test("non-existant"),
965
+ expected_exception_type=ValueError,
966
+ )
967
+
968
+ # the dispatcher rejects a non-string target
969
+ test_block(
970
+ test_name="E.dispatcher.rejects_non_string_target",
971
+ description=(
972
+ "verify `test(None)` raises TypeError, calling the dispatcher with "
973
+ "a non-string target and catching; the library raises TypeError "
974
+ "for type errors elsewhere, and a clean TypeError here points at "
975
+ "the real fault rather than the AttributeError that would surface "
976
+ "if the dispatcher tried to `.strip()` a None."
977
+ ),
978
+ )
979
+ check_raises(
980
+ test_name="E.dispatcher.rejects_non_string_target",
981
+ callable_object=lambda: test(None), # type: ignore[arg-type]
982
+ expected_exception_type=TypeError,
983
+ )
984
+
985
+ # test() is exposed at the top level and is this dispatcher
986
+ test_block(
987
+ test_name="E.dispatcher.exposed_at_top_level",
988
+ description=(
989
+ "verify `ripples.test` is exposed and is this module's dispatcher, "
990
+ "comparing ripples.test against the local `test` by identity; the "
991
+ "README documents `ripples.test(...)` as the entry point, so the "
992
+ "re-export has to be present and has to resolve to the real "
993
+ "dispatcher rather than to some object shadowing the name."
994
+ ),
995
+ )
996
+ check_truth(
997
+ test_name="E.dispatcher.exposed_at_top_level",
998
+ condition=getattr(ripples, "test", None) is test,
999
+ message_on_pass="ripples.test is wired and is the unified dispatcher.",
1000
+ message_on_fail=(
1001
+ "ripples.test is missing or is not this dispatcher; expose it with "
1002
+ "`from ._test import test` in __init__.py and list 'test' in the "
1003
+ "package's public surface."
1004
+ ),
1005
+ )
1006
+
1007
+
1008
+
1009
+ # SECTION F - END-TO-END SMOKE TEST
1010
+ #
1011
+ # Import succeeding and names resolving still does not prove the installed code
1012
+ # runs. A broken NumPy build, a corrupted file, an ABI mismatch - these surface
1013
+ # only when arithmetic actually happens. So each public entry point is called
1014
+ # once on a trivial problem with a known answer, judged on a loose band. This
1015
+ # is deliberately not an accuracy test - the submodule suites own that, with
1016
+ # tolerances down to machine epsilon. Here the only question is whether the
1017
+ # installed package computes and lands in the right place at all.
1018
+
1019
+
1020
+
1021
+ def _smoke_sine_of_first_coordinate(point: Any) -> float:
1022
+ """
1023
+ 1-D sine, taking the point as a one-element array - the convention used
1024
+ throughout the differentiation suite - so the smoke call exercises the
1025
+ same input shape the rest of the tests do.
1026
+ """
1027
+ coordinates = np.asarray(point, dtype=float)
1028
+ return float(np.sin(coordinates[0]))
1029
+
1030
+
1031
+ def _smoke_objective(point: Any) -> float:
1032
+ """A convex bowl with its minimum at (3, -1), for the optimizer smoke."""
1033
+ coordinates = np.asarray(point, dtype=float)
1034
+ return float((coordinates[0] - 3.0) ** 2 + (coordinates[1] + 1.0) ** 2)
1035
+
1036
+
1037
+ def _smoke_objective_gradient(point: Any) -> np.ndarray:
1038
+ """Analytical gradient of `_smoke_objective`."""
1039
+ coordinates = np.asarray(point, dtype=float)
1040
+ return np.array([
1041
+ 2.0 * (coordinates[0] - 3.0),
1042
+ 2.0 * (coordinates[1] + 1.0),
1043
+ ])
1044
+
1045
+
1046
+ def _smoke_identity_gradient(point: Any) -> np.ndarray:
1047
+ """
1048
+ The gradient of f(x) = 0.5 * x^T * x, which is simply x itself - the
1049
+ identity map.
1050
+
1051
+ `numerical_hessian_vector_product` differentiates a gradient, not a scalar
1052
+ function, so the first argument it expects is grad_f, not f. This helper
1053
+ is that grad_f for the trivial bowl above; its Jacobian (the Hessian of f)
1054
+ is the identity, so for any direction v the product H @ v equals v.
1055
+ """
1056
+ return np.asarray(point, dtype=float)
1057
+
1058
+
1059
+ def section_f_end_to_end_smoke() -> None:
1060
+ """
1061
+ Call each public entry point once on a trivial known-answer problem -
1062
+ the first derivative of sin at 0, the minimiser of a convex bowl, and a
1063
+ Hessian-vector product whose Hessian is the identity - and confirm each
1064
+ lands on the right value within a loose smoke band and returns the
1065
+ documented wrapper type.
1066
+ """
1067
+
1068
+ section_banner("SECTION F - End-to-end smoke test")
1069
+
1070
+ import ripples
1071
+
1072
+ # differentiation: d/dx sin(x) at 0 is 1
1073
+ test_block(
1074
+ test_name="F.smoke.first_derivative",
1075
+ description=(
1076
+ "verify nth_numerical_derivative computes d/dx sin(x) at 0 as 1, "
1077
+ "passing the 1-D sine taking a one-element point array and "
1078
+ "evaluating the first-derivative callable; this is the smallest "
1079
+ "end-to-end run of the differentiation path, and a wrong or "
1080
+ "erroring answer means the installed code does not actually "
1081
+ "compute, however cleanly it imported."
1082
+ ),
1083
+ )
1084
+ first_derivative_callable = ripples.nth_numerical_derivative(
1085
+ _smoke_sine_of_first_coordinate, derivative_order=1,
1086
+ )
1087
+ first_derivative_result = first_derivative_callable(np.array([0.0]))
1088
+ check_allclose(
1089
+ test_name="F.smoke.first_derivative",
1090
+ actual_value=float(first_derivative_result.as_float()),
1091
+ expected_value=1.0,
1092
+ relative_tolerance=1e-4, absolute_tolerance=1e-6,
1093
+ interpretation="the differentiation path runs and is correct here.",
1094
+ )
1095
+
1096
+ test_block(
1097
+ test_name="F.smoke.differentiation_result_type",
1098
+ description=(
1099
+ "verify the differentiation call returns a DifferentiationResult, "
1100
+ "checking the wrapper type of the value just produced; the "
1101
+ "documented return type is the contract downstream code is written "
1102
+ "against, so a bare ndarray slipping through would break callers "
1103
+ "that expect the wrapper's metadata."
1104
+ ),
1105
+ )
1106
+ check_truth(
1107
+ test_name="F.smoke.differentiation_result_type",
1108
+ condition=isinstance(
1109
+ first_derivative_result, ripples.DifferentiationResult
1110
+ ),
1111
+ message_on_pass="the result is a DifferentiationResult as documented.",
1112
+ message_on_fail="the result is not a DifferentiationResult.",
1113
+ )
1114
+
1115
+ # optimization: minimiser of a convex bowl
1116
+ test_block(
1117
+ test_name="F.smoke.minimise_bowl",
1118
+ description=(
1119
+ "verify minimizer locates the minimum (3, -1) of a convex bowl "
1120
+ "from the origin with BFGS and the analytical gradient, then check "
1121
+ "the minimiser within a loose band; this is the smallest "
1122
+ "end-to-end run of the optimization path and confirms the "
1123
+ "installed solver converges rather than merely imports."
1124
+ ),
1125
+ )
1126
+ optimization_result = ripples.minimizer(
1127
+ function=_smoke_objective,
1128
+ method="bfgs",
1129
+ initial_params=[0.0, 0.0],
1130
+ gradient_function=_smoke_objective_gradient,
1131
+ )
1132
+ check_allclose(
1133
+ test_name="F.smoke.minimise_bowl",
1134
+ actual_value=np.asarray(optimization_result.final_params),
1135
+ expected_value=np.array([3.0, -1.0]),
1136
+ relative_tolerance=1e-3, absolute_tolerance=1e-3,
1137
+ interpretation="the optimization path runs and converges here.",
1138
+ )
1139
+
1140
+ test_block(
1141
+ test_name="F.smoke.optimization_result_type",
1142
+ description=(
1143
+ "verify the minimiser call returns an OptimizationResult, checking "
1144
+ "the wrapper type of the value just produced; the documented "
1145
+ "return type carries the cost, counts, and termination reason "
1146
+ "callers rely on, so the contract has to hold on the installed "
1147
+ "package."
1148
+ ),
1149
+ )
1150
+ check_truth(
1151
+ test_name="F.smoke.optimization_result_type",
1152
+ condition=isinstance(
1153
+ optimization_result, ripples.OptimizationResult
1154
+ ),
1155
+ message_on_pass="the result is an OptimizationResult as documented.",
1156
+ message_on_fail="the result is not an OptimizationResult.",
1157
+ )
1158
+
1159
+ # Hessian-vector product: the gradient is the identity, so H @ v = v
1160
+ test_block(
1161
+ test_name="F.smoke.hessian_vector_product",
1162
+ description=(
1163
+ "verify numerical_hessian_vector_product returns v unchanged when "
1164
+ "given the identity-map gradient (the gradient of "
1165
+ "f(x) = 0.5 * x^T * x, whose Hessian is therefore I), evaluating "
1166
+ "H @ v at a fixed point and direction; the entry point "
1167
+ "differentiates a gradient rather than a scalar function, and the "
1168
+ "identity Hessian gives an exact expected answer for a clean "
1169
+ "end-to-end check of the matrix-free path that touches no full "
1170
+ "Hessian."
1171
+ ),
1172
+ )
1173
+ probe_point = np.array([1.0, 2.0, 3.0])
1174
+ probe_direction = np.array([1.0, 0.0, -2.0])
1175
+ hessian_vector_result = ripples.numerical_hessian_vector_product(
1176
+ _smoke_identity_gradient,
1177
+ point=probe_point,
1178
+ vector=probe_direction,
1179
+ )
1180
+ check_allclose(
1181
+ test_name="F.smoke.hessian_vector_product",
1182
+ actual_value=np.asarray(hessian_vector_result),
1183
+ expected_value=probe_direction,
1184
+ relative_tolerance=1e-4, absolute_tolerance=1e-6,
1185
+ interpretation="the matrix-free HVP path runs and is correct here.",
1186
+ )
1187
+
1188
+
1189
+
1190
+ # MAIN ENTRY POINT
1191
+ #
1192
+ # `run()` is the single orchestration point for the installation suite, shared
1193
+ # by every way of launching it: the `ripples.test("installation")` dispatcher,
1194
+ # the `python -m ripples._test` command line, and the pytest bridge below all
1195
+ # come through here, so the output and the verdicts are identical no matter how
1196
+ # the suite is started.
1197
+
1198
+
1199
+
1200
+ def run(include_benchmarks: bool = True) -> Reporter:
1201
+ """
1202
+ Execute the installation-integrity suite end to end.
1203
+
1204
+ Parameters
1205
+ ----------
1206
+ include_benchmarks : bool, default True
1207
+ Accepted for signature parity with the submodule suites so the unified
1208
+ dispatcher can drive every `run()` the same way. The installation suite
1209
+ has no timing-only sections, so the flag has no effect here.
1210
+
1211
+ Returns
1212
+ -------
1213
+ Reporter
1214
+ The reporter holding the run's tallies (`passed`, `failed`,
1215
+ `skipped`, `info`) and its failure records. It is returned rather
1216
+ than a bare count so a caller aggregating several suites - as
1217
+ `test("all")` does - can fold these numbers into one combined summary.
1218
+ """
1219
+
1220
+ # A fresh slate on every call: the module-level reporter is reused, so
1221
+ # without this a second run would keep adding to the first run's counts.
1222
+ REPORT.reset()
1223
+
1224
+ ordered_sections = [
1225
+ section_a_import_and_metadata,
1226
+ section_b_runtime_environment,
1227
+ section_c_public_api_surface,
1228
+ section_d_submodule_reexport_identity,
1229
+ section_e_test_suite_wiring,
1230
+ section_f_end_to_end_smoke,
1231
+ ]
1232
+
1233
+ print("=" * SEPARATOR_WIDTH)
1234
+ print(
1235
+ " ripples - installation-integrity suite ".center(SEPARATOR_WIDTH, "=")
1236
+ )
1237
+ print("=" * SEPARATOR_WIDTH)
1238
+ print(
1239
+ "Reading order: each block states what it checks and why it matters, "
1240
+ "then\nprints the RESULT and a VERDICT (pass / fail / info)."
1241
+ )
1242
+
1243
+ for section_runner in ordered_sections:
1244
+ try:
1245
+ section_runner()
1246
+ except Exception as section_exception:
1247
+ # An exception escaping a section is itself a failure; record it,
1248
+ # print the traceback, and carry on with the next section so a
1249
+ # single fault cannot abort the whole suite.
1250
+ import traceback
1251
+ REPORT.record_fail(
1252
+ test_name=section_runner.__name__,
1253
+ message=(
1254
+ f"section raised an unhandled "
1255
+ f"{type(section_exception).__name__}: "
1256
+ f"{section_exception}"
1257
+ ),
1258
+ )
1259
+ print()
1260
+ print(" UNCAUGHT EXCEPTION ".center(SEPARATOR_WIDTH, "!"))
1261
+ traceback.print_exc()
1262
+ print("!" * SEPARATOR_WIDTH)
1263
+
1264
+ REPORT.print_summary()
1265
+ return REPORT
1266
+
1267
+
1268
+
1269
+ # UNIFIED DISPATCHER
1270
+ #
1271
+ # `test()` is the one front door named in the README and in both submodule
1272
+ # suites. It routes to the installation checks above, to a single submodule
1273
+ # suite, or to everything at once. In the "all" case it runs each suite in
1274
+ # turn - each prints its own report - and then folds the per-suite tallies into
1275
+ # one combined summary, so a caller sees both the detail and the grand total.
1276
+ #
1277
+ # It is re-exported at the top level from ripples/__init__.py (`from ._test
1278
+ # import test`, with 'test' listed in the package's public surface), which is
1279
+ # what makes `ripples.test(...)` resolve to the function defined here.
1280
+
1281
+
1282
+
1283
+ # The targets `test()` understands, each mapped to a short list of accepted
1284
+ # spellings, so a caller can write the obvious word and have it resolve.
1285
+ _TARGET_ALIASES = {
1286
+ "installation": ("installation", "install", "root", "integrity"),
1287
+ "differentiation": ("differentiation", "diff"),
1288
+ "optimization": ("optimization", "optimisation", "opt"),
1289
+ "all": ("all", "everything", "full"),
1290
+ }
1291
+
1292
+
1293
+ def _canonical_target(requested_target: str) -> str:
1294
+ """
1295
+ Map a requested target spelling to its canonical key.
1296
+
1297
+ Raises
1298
+ ------
1299
+ TypeError
1300
+ If `requested_target` is not a string.
1301
+ ValueError
1302
+ If the string does not match any of the recognised target aliases.
1303
+ """
1304
+ if not isinstance(requested_target, str):
1305
+ raise TypeError(
1306
+ f"test target must be a string, got "
1307
+ f"{type(requested_target).__name__}: {requested_target!r}."
1308
+ )
1309
+ requested_lower = requested_target.strip().lower()
1310
+ for canonical_name, accepted_spellings in _TARGET_ALIASES.items():
1311
+ if requested_lower in accepted_spellings:
1312
+ return canonical_name
1313
+ raise ValueError(
1314
+ f"unknown test target {requested_target!r}; choose one of "
1315
+ f"'installation', 'differentiation', 'optimization', or 'all'."
1316
+ )
1317
+
1318
+
1319
+ def _print_combined_summary(
1320
+ named_reporters: List[Tuple[str, Reporter]],
1321
+ ) -> None:
1322
+ """Print one grand-total table across several suites' reporters."""
1323
+
1324
+ print()
1325
+ print("=" * SEPARATOR_WIDTH)
1326
+ print(" COMBINED SUMMARY (ALL SUITES) ".center(SEPARATOR_WIDTH, "="))
1327
+ print("=" * SEPARATOR_WIDTH)
1328
+ print(f" {'suite':<20}{'passed':>9}{'failed':>9}"
1329
+ f"{'skipped':>9}{'info':>9}")
1330
+ print(" " + "-" * (SEPARATOR_WIDTH - 4))
1331
+
1332
+ total_passed = total_failed = total_skipped = total_info = 0
1333
+ for suite_name, suite_reporter in named_reporters:
1334
+ total_passed += suite_reporter.passed
1335
+ total_failed += suite_reporter.failed
1336
+ total_skipped += suite_reporter.skipped
1337
+ total_info += suite_reporter.info
1338
+ print(f" {suite_name:<20}{suite_reporter.passed:>9}"
1339
+ f"{suite_reporter.failed:>9}{suite_reporter.skipped:>9}"
1340
+ f"{suite_reporter.info:>9}")
1341
+
1342
+ print(" " + "-" * (SEPARATOR_WIDTH - 4))
1343
+ print(f" {'TOTAL':<20}{total_passed:>9}{total_failed:>9}"
1344
+ f"{total_skipped:>9}{total_info:>9}")
1345
+ print("=" * SEPARATOR_WIDTH)
1346
+ if total_failed == 0:
1347
+ print(" All suites passed.")
1348
+ else:
1349
+ print(f" {total_failed} check(s) failed across all suites; "
1350
+ f"see each suite's own FAILURES block above.")
1351
+ print("=" * SEPARATOR_WIDTH)
1352
+
1353
+
1354
+
1355
+ def test(
1356
+ which: Literal[
1357
+ 'installation', 'differentiation', 'optimization', 'all'
1358
+ ] = "all",
1359
+ include_benchmarks: bool = True
1360
+ ) -> int:
1361
+ """
1362
+ Run the Ripples test suites and return the number of failing checks.
1363
+
1364
+ This is the unified entry point referenced throughout the documentation.
1365
+ It runs the installation-integrity checks defined in this module, a single
1366
+ submodule suite, or every suite at once, and returns the total failure
1367
+ count so the result reads as a truthiness gate: ``0`` means everything
1368
+ passed.
1369
+
1370
+ Parameters
1371
+ ----------
1372
+ which : 'installation', 'differentiation', 'optimization' or 'all', \
1373
+ default 'all'
1374
+ Which suite to run. Accepted (case-insensitive) targets:
1375
+
1376
+ - ``'installation'`` - the integrity checks in this module only.
1377
+ - ``'differentiation'`` - the differentiation suite only.
1378
+ - ``'optimization'`` - the optimization suite only.
1379
+ - ``'all'`` - the installation checks followed by both submodule
1380
+ suites, with a combined summary printed at the end.
1381
+
1382
+ Common alternative spellings (``'install'``, ``'diff'``, ``'opt'``,
1383
+ ``'everything'``, ...) are accepted as well.
1384
+ include_benchmarks : bool, default True
1385
+ Forwarded unchanged to each submodule suite, where it toggles the
1386
+ timing-only sections. The installation suite has no benchmarks, so the
1387
+ flag does not affect that part of an ``'all'`` run.
1388
+
1389
+ Returns
1390
+ -------
1391
+ int
1392
+ The number of failing checks across whatever was run; ``0`` on a fully
1393
+ passing run, which is also the convention continuous-integration
1394
+ systems read from the process exit code.
1395
+
1396
+ Raises
1397
+ ------
1398
+ TypeError
1399
+ If `which` is not a string.
1400
+ ValueError
1401
+ If `which` is not one of the recognised targets.
1402
+
1403
+ Examples
1404
+ --------
1405
+ >>> import ripples
1406
+ >>> ripples.test("installation") # doctest: +SKIP
1407
+ >>> ripples.test() # every suite, one summary # doctest: +SKIP
1408
+ """
1409
+
1410
+ canonical_target = _canonical_target(which)
1411
+
1412
+ if canonical_target == "installation":
1413
+ return run(include_benchmarks=include_benchmarks).failed
1414
+
1415
+ if canonical_target == "differentiation":
1416
+ differentiation_tests = importlib.import_module(
1417
+ "ripples.differentiation._test"
1418
+ )
1419
+ return differentiation_tests.run(
1420
+ include_benchmarks=include_benchmarks
1421
+ ).failed
1422
+
1423
+ if canonical_target == "optimization":
1424
+ optimization_tests = importlib.import_module(
1425
+ "ripples.optimization._test"
1426
+ )
1427
+ return optimization_tests.run(
1428
+ include_benchmarks=include_benchmarks
1429
+ ).failed
1430
+
1431
+ # canonical_target == "all": run each suite in turn, then combine.
1432
+ differentiation_tests = importlib.import_module(
1433
+ "ripples.differentiation._test"
1434
+ )
1435
+ optimization_tests = importlib.import_module(
1436
+ "ripples.optimization._test"
1437
+ )
1438
+
1439
+ named_reporters: List[Tuple[str, Reporter]] = [
1440
+ ("installation", run(include_benchmarks=include_benchmarks)),
1441
+ (
1442
+ "differentiation",
1443
+ differentiation_tests.run(include_benchmarks=include_benchmarks),
1444
+ ),
1445
+ (
1446
+ "optimization",
1447
+ optimization_tests.run(include_benchmarks=include_benchmarks),
1448
+ ),
1449
+ ]
1450
+
1451
+ _print_combined_summary(named_reporters)
1452
+ return sum(suite_reporter.failed for _, suite_reporter in named_reporters)
1453
+
1454
+
1455
+
1456
+ def main(argv: Optional[List[str]] = None) -> int:
1457
+ """
1458
+ Command-line entry point for `python -m ripples._test`.
1459
+
1460
+ By default it runs the installation-integrity suite alone. With ``--all``
1461
+ it chains the two submodule suites after it and prints a combined summary;
1462
+ with ``--no-benchmarks`` it forwards `include_benchmarks=False` to whatever
1463
+ it runs. Returns the number of failures so the process exit code is 0 on a
1464
+ clean run and non-zero otherwise - the convention continuous-integration
1465
+ systems read.
1466
+
1467
+ The CLI dispatches through `ripples.test` rather than the local `test`
1468
+ defined below, because `python -m ripples._test` loads this source file
1469
+ twice (once as `ripples._test` during the package import, once again as
1470
+ `__main__` for the CLI run) and the two copies hold separate function
1471
+ objects; going through the package attribute ensures the identity check
1472
+ in Section E resolves to the same `test` object regardless of how the
1473
+ suite was launched.
1474
+ """
1475
+ arguments = sys.argv[1:] if argv is None else argv
1476
+ include_benchmarks = "--no-benchmarks" not in arguments
1477
+ target = "all" if "--all" in arguments else "installation"
1478
+ import ripples
1479
+ return ripples.test(which=target, include_benchmarks=include_benchmarks)
1480
+
1481
+
1482
+
1483
+ # PYTEST BRIDGE
1484
+ #
1485
+ # A single function so that pytest - which continuous integration runs on every
1486
+ # push - actually gates on this suite. It drives the same `run()` as every
1487
+ # other entry point (benchmarks off, since pytest is a correctness gate, not a
1488
+ # stopwatch) and turns a non-zero failure count into a failed test. The per-
1489
+ # test report is printed by `run()`; pytest shows it with `-s` or on failure.
1490
+
1491
+
1492
+
1493
+ def test_installation_suite() -> None:
1494
+ """Fail under pytest if any installation-integrity check fails."""
1495
+ reporter = run(include_benchmarks=False)
1496
+ assert reporter.failed == 0, (
1497
+ f"{reporter.failed} installation check(s) failed; "
1498
+ f"see the printed report for the what / why of each."
1499
+ )
1500
+
1501
+
1502
+
1503
+ if __name__ == "__main__":
1504
+ sys.exit(main())