diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index a3b2015..55ecfd5 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -51,7 +51,6 @@ Rules for a host test: it fails when a key it knows is missing (no silent skip); 3. **CF-07, 08, 10, 11, 12, 13** and all of RUN, ASY, HRET and RET-10/11/14 are sequences with prose expectations; the spec defines no machine-executable vocabulary for them, so only Tools' own fixtures (RET-01..09, 12, 13) are structured, and the others are interpreted by each host's harness. 4. **Length units** (2.1.4): the spec lists `um`, `micron(s)`, `micrometer/-metre`, `nm`, `mm`, `cm`, `m` and the Angstrom spellings, but CAL-03 uses the long form `millimeter`. The file lists `millimeter` and `millimetre` (needed by CAL-03) and no other long form (`nanometer`, `centimeter`, `meter`); case sensitivity, plural forms and surrounding spaces are not specified. 5. **Number limits** (6): the refusal sentence is given for integers only (`'L' must be <= 9007199254740991`, the `>=` form from HOST_PARITY I06); the float sentence and the message when a GUI box cannot hold the number are not specified. The spec writes the keys as `limits.form_integer_max = 9007199254740991`; the file nests the number as `limits.form_integer_max.value` (the layout of the HOST_PARITY sketch) so that it can carry `status` and `spec`. -6. **Tools divergence found while writing the file**: the notebook form refuses an integer beyond 2**53-1 with `'L' is N, beyond the +-9007199254740991 the form can hold exactly: ...`, not with the `'L' must be <= 9007199254740991` template of 6.2 (NUM-02, NUM-03). To fix with the notebook change; the file follows the spec. 7. **Lookup limits** (5.4: one in flight, 8 pending, 60 s) and the other working decisions of SPEC 8 are `status: default`, not yet confirmed. 8. **Napari N-D, QuPath downsample d != 1, TimeIntervalOf and ZStepOf** fixtures wait for Wave 2 (spec header); CF-14 is data, but no host exports at d != 1 yet. 9. **Run-identity and async fixtures have no pass criteria for timing** (no clock or barrier vocabulary): each host's harness decides how to build the "deterministic barriers" of 5.7. diff --git a/docs/REGRESSION_LEDGER.md b/docs/REGRESSION_LEDGER.md index 108595d..1ae9056 100644 --- a/docs/REGRESSION_LEDGER.md +++ b/docs/REGRESSION_LEDGER.md @@ -17,6 +17,7 @@ How to add one: [TESTING.md](TESTING.md#the-ledger). | 2026-10-08 | F3: duplicate and empty column names of a Table input are renamed (`a.1`, `Unnamed: 0`) | `pd.read_csv` header mangling | this PR | `tests/test_table_input.py::TableInput.test_duplicate_and_empty_names_are_kept`; also `test_roundtrip_matrix.py::Matrix.test_worker_protocol` | | 2026-10-08 | F4: a missing, empty or binary table file fails with `FileNotFoundError` / `EmptyDataError` / `UnicodeDecodeError` | no `file_not_found` / unreadable check for tables as for images | this PR | `tests/test_table_input.py::TableInput.test_unreadable_files_are_tool_errors`; also `test_roundtrip_matrix.py::Matrix.test_worker_protocol` | | 2026-10-12 | Found in the retention spec (Tools #55): a folder the researcher kept under `results/` (for example `20261008T120000_reanalysis_backup`) was deleted by a later `labconstrictor-tools run`, and every sub-folder of `runs/` beyond the newest 50 was deleted, whoever made it | The CLI pruned before creating a new folder by the name pattern `^\d{8}T\d{6}_` alone (a foreign folder with a timestamp name matches), and `runs._prune` removed every sub-folder; neither looked at who made the folder or whether the run had finished (an active run could be deleted) | [#112](https://github.com/CellMigrationLab/LabConstrictor-Tools/pull/112): `retention.prune` removes only folders with a valid `.lc-owner.json` and a `.lc-done.json` (never a symlink, junction, foreign, legacy, crashed or active folder), under an exclusive lock, newest 20 / 50 kept; `labconstrictor-tools prune`; `run` and `runs.record` write the markers and call it after the run; feature `retention_prune` | `test_retention.py::Eligibility.test_RET_01_a_foreign_folder_survives_and_the_newest_20_owned_remain`, `test_retention.py::Eligibility.test_RET_02_runs_keep_50_and_a_foreign_run_folder_survives`, `test_retention.py::Eligibility.test_RET_03_folders_without_a_done_file_are_never_removed`, `test_retention.py::UnsafePaths.test_RET_05_a_symlink_to_an_owned_looking_folder_outside_is_left_alone`, `test_retention.py::Eligibility.test_RET_07_legacy_folders_without_a_marker_are_never_removed`, `test_retention.py::Lock.test_RET_08_four_prunes_at_once_leave_exactly_20_and_every_process_exits_0`, `test_retention.py::CliCreatesOwnedFolders.test_a_results_folder_made_by_run_has_its_owner_marker_and_never_removes_anything` (and the sabotage rows `retention:*` in `test_sabotage.py`) | +| 2026-10-12 | Found writing `conformance.json`: typing an integer beyond 2**53-1 (or below -(2**53-1)) into a notebook form number box was refused with `'L' is N, beyond the +-9007199254740991 the form can hold exactly: type a smaller number, ...`, not with the sentence every host must show (`'L' must be <= 9007199254740991`, `'L' must be >= -9007199254740991`; spec 6.2, vectors NUM-02 and NUM-03) | The wording of `notebook.ToolForm.values` was written before the spec and says the value and the advice, where the other hosts and the worker say the field and the limit | this PR: `values` raises `invalid_parameter` with the templates `number_above_maximum` / `number_below_minimum` of `conformance.json`; the value is still never changed and no run starts | `test_notebook_unset_and_range.py::UnsetAndRange.test_NUM_02_and_NUM_03_use_the_sentences_of_the_spec_and_start_no_run`, `test_notebook_unset_and_range.py::UnsetAndRange.test_NUM_01_the_largest_exact_integer_is_sent_unchanged` (and the sabotage rows `notebook form:*` in `test_sabotage.py`) | | 2026-10-08 | F7: table text cells such as `NA`, `null`, `None` arrive as NaN | pandas default NA values | this PR | `tests/test_table_input.py::TableInput.test_na_words_stay_text_unless_numeric`; also `test_roundtrip_matrix.py::Matrix.test_worker_protocol` | | 2026-10-08 | F1: a number parameter given text, a list, NaN or infinity fails with `[ValueError]`/`[TypeError]`/`[OverflowError]` instead of `invalid_parameter` | `convert._load_one` calls `int(value)` / `float(value)` before it checks anything | this PR | `test_error_codes.py::NumbersMustBeNumbers.test_integer_given_something_else_is_invalid_parameter_naming_what_arrived`, `test_roundtrip_matrix.py::Matrix.test_worker_protocol` (`echo_int/word` and 12 more), `test_roundtrip_properties.py::Properties.test_only_toolerror_integer`, `test_roundtrip_properties.py::Properties.test_only_toolerror_float` | | 2026-10-08 | F5: a tool that returns the wrong kind of value for an output gets a raw Python exception code | `build_results` and its writers trust the shape and type of what a tool returns | this PR | `test_error_codes.py::WrongReturns.test_each_kind_of_wrong_value_names_the_output_and_what_was_expected`, `test_roundtrip_failures.py::WhatAToolReturns.test_the_wrong_type_is_reported_with_a_tool_error_code` | diff --git a/labconstrictor_tools/notebook.py b/labconstrictor_tools/notebook.py index e421aab..4e3fd54 100644 --- a/labconstrictor_tools/notebook.py +++ b/labconstrictor_tools/notebook.py @@ -163,11 +163,14 @@ def values(self) -> dict[str, Any]: if name in self.set_toggles and not self.set_toggles[name].value: continue # optional and not set: the tool receives None value = self.controls[name].value - if param["type"] == "integer" and abs(value) > MAX_FORM_INTEGER: + # the refusal sentences are the templates of SPEC_WRONG_SCIENCE 6.2 (conformance.json: number_above_maximum, number_below_minimum) + if param["type"] == "integer" and value > MAX_FORM_INTEGER: raise T.ToolError( - "invalid_parameter", - "'%s' is %d, beyond the +-%d the form can hold exactly: type a smaller number, or run the tool " - "from the command line or a snippet" % (param["label"], value, MAX_FORM_INTEGER), + "invalid_parameter", "'%s' must be <= %d" % (param["label"], MAX_FORM_INTEGER) + ) + if param["type"] == "integer" and value < -MAX_FORM_INTEGER: + raise T.ToolError( + "invalid_parameter", "'%s' must be >= %d" % (param["label"], -MAX_FORM_INTEGER) ) if param["type"] in ("image", "labels", "table", "file", "folder") and not str(value).strip(): continue diff --git a/tests/test_notebook_unset_and_range.py b/tests/test_notebook_unset_and_range.py index 99f34da..ba2df11 100644 --- a/tests/test_notebook_unset_and_range.py +++ b/tests/test_notebook_unset_and_range.py @@ -1,11 +1,12 @@ """Notebook form: an optional parameter can be left unset (F9) and numbers are never clamped silently (F8).""" +import re import unittest from typing import Annotated import _paths # noqa: F401 (must come first) -from labconstrictor_tools import Max, Min, Scalars, tool +from labconstrictor_tools import Max, Min, Scalars, conformance, tool from labconstrictor_tools.notebook import MAX_FORM_INTEGER, ToolForm try: @@ -28,6 +29,15 @@ def optional_things( return {"n": n, "x": x, "s": s, "flag": flag, "big": big, "bounded": bounded} +RUNS: list[int] = [] + + +@tool("Counted") +def counted(whole: int = 0) -> Scalars: + RUNS.append(whole) + return {"whole": whole} + + @unittest.skipUnless(HAVE_WIDGETS, "ipywidgets not installed") class UnsetAndRange(unittest.TestCase): def received(self, form): @@ -72,6 +82,45 @@ def test_an_integer_the_form_cannot_hold_exactly_is_refused_with_a_message(self) form.controls["big"].value = MAX_FORM_INTEGER self.assertEqual(self.received(form)["big"], MAX_FORM_INTEGER) + def test_NUM_02_and_NUM_03_use_the_sentences_of_the_spec_and_start_no_run(self): + # the vectors and the templates come from conformance.json, the sentences are not written out here + data = conformance.load() + cases = {c["id"]: c for c in data["wrong_science"]["number_limits"]["cases"]} + limit = data["limits"]["form_integer_max"]["value"] + self.assertEqual(limit, MAX_FORM_INTEGER) + templates = data["messages"] + for ident, template, bound in ( + ("NUM-02", templates["number_above_maximum"]["template"], {"maximum": limit}), + ("NUM-03", templates["number_below_minimum"]["template"], {"minimum": -limit}), + ): + with self.subTest(ident): + case = cases[ident] + sentence = re.search(r"refused: ('L' must be [<>]= -?\d+);", case["expect"]) + self.assertIsNotNone(sentence, case["expect"]) + form = ToolForm(counted) + label = form.schema["inputs"][0]["label"] + expected = template.format(label=label, **bound) + self.assertEqual(expected, sentence.group(1).replace("'L'", "'%s'" % label)) + RUNS.clear() + form.controls["whole"].value = int(case["typed"]) + self.assertIsNone(form.run()) + self.assertEqual(form.status.value, "\u2716 invalid_parameter: %s" % expected) + self.assertEqual(RUNS, [], "no run starts") + self.assertEqual( + form.controls["whole"].value, int(case["typed"]), "the value is never changed" + ) + with self.assertRaises(Exception) as caught: + form.values() + self.assertEqual(caught.exception.message, expected) # type: ignore[attr-defined] + + def test_NUM_01_the_largest_exact_integer_is_sent_unchanged(self): + case = {c["id"]: c for c in conformance.load()["wrong_science"]["number_limits"]["cases"]}["NUM-01"] + form = ToolForm(counted) + RUNS.clear() + form.controls["whole"].value = int(case["typed"]) + self.assertIsNotNone(form.run(), form.status.value) + self.assertEqual(RUNS, [9007199254740991]) + def test_a_declared_min_max_pair_is_still_enforced_by_the_box(self): form = ToolForm(optional_things) self.assertEqual(type(form.controls["bounded"]).__name__, "BoundedIntText") diff --git a/tests/test_sabotage.py b/tests/test_sabotage.py index 196ea43..6a86746 100644 --- a/tests/test_sabotage.py +++ b/tests/test_sabotage.py @@ -709,6 +709,43 @@ class Break: " print(path().parent)\n", unittest_slice("test_conformance.Loader.test_the_command_line_gives_the_path_and_validates"), ), + # ---- the notebook form's refusal of an integer beyond 2**53 - 1 (SPEC_WRONG_SCIENCE 6.2, NUM-02, NUM-03) + Break( + "notebook form: an integer above the limit gets the old sentence, not the template of 6.2", + "notebook.py", + '"\'%s\' must be <= %d" % (param["label"], MAX_FORM_INTEGER)', + '"\'%s\' is %d, beyond the limit" % (param["label"], MAX_FORM_INTEGER)', + unittest_slice( + "test_notebook_unset_and_range.UnsetAndRange.test_NUM_02_and_NUM_03_use_the_sentences_of_the_spec_and_start_no_run" + ), + ), + Break( + "notebook form: the sentence for an integer below the limit loses its minus sign", + "notebook.py", + '"\'%s\' must be >= %d" % (param["label"], -MAX_FORM_INTEGER)', + '"\'%s\' must be >= %d" % (param["label"], MAX_FORM_INTEGER)', + unittest_slice( + "test_notebook_unset_and_range.UnsetAndRange.test_NUM_02_and_NUM_03_use_the_sentences_of_the_spec_and_start_no_run" + ), + ), + Break( + "notebook form: the largest exact integer is refused", + "notebook.py", + 'param["type"] == "integer" and value > MAX_FORM_INTEGER', + 'param["type"] == "integer" and value >= MAX_FORM_INTEGER', + unittest_slice( + "test_notebook_unset_and_range.UnsetAndRange.test_NUM_01_the_largest_exact_integer_is_sent_unchanged" + ), + ), + Break( + "notebook form: an integer below the negative limit is accepted", + "notebook.py", + 'param["type"] == "integer" and value < -MAX_FORM_INTEGER', + 'param["type"] == "integer" and value < -MAX_FORM_INTEGER - 2**60', + unittest_slice( + "test_notebook_unset_and_range.UnsetAndRange.test_NUM_02_and_NUM_03_use_the_sentences_of_the_spec_and_start_no_run" + ), + ), ]