Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions docs/REGRESSION_LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
11 changes: 7 additions & 4 deletions labconstrictor_tools/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion tests/test_notebook_unset_and_range.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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 <b>invalid_parameter</b>: %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")
Expand Down
37 changes: 37 additions & 0 deletions tests/test_sabotage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
),
),
]


Expand Down
Loading