Add warnings to the compliance checker - #18
Merged
Conversation
Every finding the checker reports has to reach two places: the line a modeller reads in the log, and the counter the synthesis block at the top of that log adds up. Keeping those in step meant each check returning an error count and each caller adding it on, so roughly sixty writes ran in parallel with sixty pieces of bookkeeping and a classification could only be changed in both places at once. Introduce a Reporter that owns the log file and the counts, so that the write is the count. Reporters nest: category() scopes findings to one of the checker's reporting categories, and child() opens a sub-total that still rolls up into its parent. This commit migrates the leaf checks -- _check_naming, _check_file_variables, _check_variable_dimensions, _check_numerical, _check_spatial, _check_time, _check_snapshot_time_axis and _check_attributes -- off returned counts. _run_variable_checks still hands its callers the same five integers, now read back from the reporter, so the aggregation above it is untouched; that is the next commit. _check_attributes has always labelled its findings 'ERROR (attributes)' where every other check writes 'ERROR'. The reporter reproduces that through a qualifier rather than normalising it, so the log stays byte-identical here and its warnings will read 'WARNING (attributes)' for the same reason. No behaviour change: the reference log is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_run_variable_checks, _process_single_file, _process_single_experiment and _process_experiments stop returning and re-summing error counts. Each layer opens a child reporter instead, so a file's footer, an experiment's console line and the run-wide synthesis all read sub-totals of the same counting, rather than three additions that have to agree. The reporter's error() grows a `count`, for the one finding that is several -- the missing mandatory variables of an experiment, reported on one line and counted once per variable, exactly as before. No behaviour change: the reference log is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The log has promised warnings since it was written and never delivered any: two lines were hardcoded, "No warnings." under every file's footer and "0 warning(s) detected." in the synthesis. With no warning severity in the counting path, anything worth mentioning at all had to be reported as an error -- which is why the extra litemp snapshots of PR ismip#16 are errors today. Add warning() alongside error(), counted separately by category, and make the four places that report counts tell the truth: the per-file footer, the synthesis block, the console summaries and run_checker's summary dict. Warnings are kept clear of the verdict throughout: a file with warnings and no errors still reads "No errors. Good job !", and the console phrases its warning count so it cannot be mistaken for a failure. No check emits a warning yet, so every count is still zero. The synthesis block does gain the per-category warning breakdown that mirrors the error one, which is the only change in the reference log. Unit tests for the reporter itself: the line formats, the split between severities and categories, and the roll-up from a file's sub-total to the run's total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
State the rule once -- in the header comment of checker.py, where a new check is written, and in the README, where a modeller reading a log will look -- so that the next classification is settled by citing it rather than by arguing the case again. Documentation only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR ismip#16 left this gap on the record: litemp is the data request's only x,y,z,t variable, and so the only one whose sparse snapshot axis _check_time looks at, but it is not mandatory and the golden fixture did not ask for non-mandatory variables. The whole snapshot branch was therefore checked only by assertions, never by a stored log. The static x,y refgeoid was in the same position. Parametrise the fixture and add a second reference generated with include_non_mandatory=True. The existing reference keeps its meaning and is unchanged; the new one covers 42 files rather than 27. Beyond closing the gap, this gives the severity changes that follow a visible diff in a committed log rather than only a number in an assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A file carries one variable of the data request, and _allowed_file_
variables understood three of the ways CF lets that variable name a
companion -- bounds, grid_mapping and coordinates -- but not
cell_measures or ancillary_variables. A file carrying a cell area its
requested variable is normalised by was therefore reported as holding an
unexpected variable, which is a false positive: the companion is there
precisely so that the requested variable means what it says.
Recognise both, dropping the measure keywords of cell_measures
('area: areacello') and keeping the variable names.
This removes the false positive outright rather than demoting it, and
lands before any severity change so that the two are not confused.
Neither reference log changes; the generator writes no such companions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The requested variable is present and fully checkable either way, and a reader taking it out of the file is unaffected by what sits beside it, so nothing downstream has to work around this. What an extra variable says is that the file was probably not written for this submission -- worth a look, and not a fault. Neither reference log changes; no generated file carries an extra. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sub-test 5 of _check_attributes checked two dtypes as though they were one finding. They are not. A float64 data variable is twice the size it is supposed to be, for the archive and for every downstream user who has to move it, so it stays an error. A time axis is one number per record, so storing it as float64 cannot meaningfully inflate a file: the size argument that governs the data variable does not reach it, and it becomes a warning. Neither reference log changes; the generator writes float32 time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings settle together here. An x,y,z,t file carrying a snapshot the experiment does not call for was made an error in PR ismip#16, and that was a stopgap: no warning severity existed, so the choice was between failing the file and saying nothing. It is a warning. The data request specifies these years as a *minimum* set, so over-delivering 3D temperature is not non-compliance -- but a year nobody asked for is usually a sign of a mistake, so it is worth naming. The asymmetry with the annual axis, where an extra year stays an error, is deliberate: that axis is pinned end to end by experiments_ismip7.csv, so an extra year there means the file does not match the experiment it names. A snapshot at 2000 then needs no special case. It was accepted in silence, which told a modeller following the old README nothing at all; reported as an unrequested snapshot it gets the honest answer -- it is here, it was not asked for, it is not held against you -- and TOLERATED_SNAPSHOT_YEARS goes. Neither reference log changes; the generator writes only required snapshots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_process_single_experiment tracked only the mandatory variables, so a group that meant to submit litemp and lost it in a script got no signal whatsoever. The design work here is in the saying, not the checking, because a deliberate omission is the common case: not every model supports every non-mandatory variable, GIA being the obvious one. So the warning says "carries no files for" rather than "missing", says it once per experiment naming all of them rather than once per variable -- which would make a model with a narrow scope look far worse than one that dropped a single file -- says out loud that this is expected if the model does not represent them, and stays out of the trailing naming-issues report, which is the part of the log that reads as a list of faults. It is scoped to the selected --variable-list, exactly as the mandatory-variable check is, so a run over ismip7_scalars says nothing about x,y,t variables it was never asked to look at. There is no suppression when an experiment submitted nothing optional at all: for a full submission that is precisely the case worth naming. The synthesis category is renamed from 'Mandatory variables' to 'Variable presence', which the new warning otherwise contradicts. Both reference logs change: the mandatory-only submission now carries the warning, and both carry the renamed category. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An optional not_modelled.txt in the source directory silences the "carries no files for" warning for the variables it names. Groups already submit a README explaining what they submitted and why some variables are absent, so the checker has no need to ask for that list again -- and an explanatory list does not belong on a command line, where it would be long and would have to be retyped for every run. One name per line, '#' starting a comment, so the reason can sit beside the name. It suppresses warnings only, and two rules keep it from becoming a way to hide problems. A mandatory variable named in it is still a missing-mandatory error, and the claim itself is reported as a further error: the list is a statement about optional variables, and a submission cannot opt out of the data request with it. A name that is not in the data request at all is an error too -- typo or misunderstanding, both better said plainly than left to be inferred from a warning that did not go away, which would protect nothing while looking like protection. Since the declaration is about the submission rather than about one run, a mandatory variable is recognised whichever --variable-list was selected. What was declared is echoed into the log, so the archived record shows what was claimed rather than merely that a warning did not appear. An absent file changes nothing, and neither reference log moves. Also sorts the candidates handed to difflib for the "closest requested name" hints. They came from a set, so two equally close names resolved by iteration order and the log could differ between runs of the same checker over the same files -- which is the one thing this tool is not allowed to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is the substance of issue ismip#10. Some min/max bounds "are dependent on the forcing, input data and model implementation", so failing a run on them is too strong -- but which ones is a per-variable question the checker cannot answer from its side. So the decision is that the classification lives in the data request, one cell per variable row, and this commit delivers the mechanism and changes no classification. Add a range_severity column to ISMIP7_variable_request.csv holding `error` or `warning`, read it in _load_criteria, and honour it in _check_numerical. Every shipped row is `error`, so behaviour and both reference logs are unchanged. Anything the column does not say -- a blank cell, an unrecognised value, or the column missing altogether -- means `error`, which is what makes it safe to add now and fill in gradually. Switching a variable is then a one-cell, data-only diff that needs no code review and no reasoning about the checker, which is what makes the case-by-case triage after this cheap enough to actually happen and easy to revise as feedback comes in from groups. The mechanism is tested against a synthetic criteria row rather than the shipped CSV, so the data and the mechanism are tested independently. Region granularity (range_severity_ais / range_severity_gris) is a strictly larger version of the same design and can replace the single column later without touching the check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The checker always exited 0, so nothing could gate on it. Now it exits non-zero when there were errors and zero when there were only warnings, which is what having warnings is for. Two details this touches beyond the obvious. `python -m isschecker` discarded main()'s return value, so the module entry point had to raise SystemExit to match the console script, which setuptools already wires to sys.exit. And the paths where the checker gives up -- directory not found, no .nc files in it, and a dataset it could not read at all -- return a summary whose error count is zero because nothing was checked; on an error-based exit status those would have exited 0 after checking nothing, which is worse than always exiting 0. A `fatal` flag in the summary tells that apart from a clean run. Last in the series because it is the only change here that alters how the tool behaves for existing automation, so it is the one worth being able to release, revert or announce on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI ran tests/test_compliance_checker.py by name, so test_golden_log.py never ran there -- despite its whole reason for existing being to catch a dependency release inside the supported ranges changing the log, which is what running it at both the floor and the latest environment is for. The new tests/test_reporter.py would have been skipped for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #10. Also settles the stopgap PR #16 left behind, and closes the golden-log gap it recorded.
What this is for
The log has promised warnings since it was written and never delivered any:
No warnings.under every file's footer and0 warning(s) detected.in the synthesis were hardcoded strings. Everything else was an error, because there was no other severity in the counting path — each_check_*function wrote" - ERROR: ..."and returned anintthat its caller added on, and there was nowhere for a second severity to live.So the checker could not distinguish "this file is wrong" from "this file is unusual, please look at it". Issue #10 asks for exactly that distinction, quoting @hgoelzer: some of the min/max bounds "are dependent on the forcing, input data and model implementation", so failing a run on them is too strong. The extra-
litemp-snapshot check landed as an error in PR #16 for the same reason — not because it was the right severity, but because it was the only one available.The rule
One rule, now stated in the header comment of
checker.pyand in the README, so that the next check is classified by citing it rather than by arguing the case again.ERROR — the file, as written, is unusable for the intended analysis, departs from the protocol in a way that changes the science, or fails the data-hygiene requirements this archive is committing to. That last clause is deliberate: the output will be served to the broader community for analysis for years, so uniformity of encoding is a product requirement rather than a stylistic preference, and "a reader could cope with it" is not grounds for a warning.
WARNING — the file is usable, the science is unaffected, and nothing downstream has to work around it, but it departs from what the request asked for in a way the modeller should look at and may reasonably have intended.
Three consequences keep warnings from quietly becoming errors: they never enter the error count or change a file's verdict, they never affect the exit status, and a check whose failure means the checker could not read something stays an error — a warning never suppresses a later check.
What changed severity
Four findings move, each in its own commit so that any one of them can be reversed in review without disturbing the others.
Snapshot nominal years the experiment does not call for are now a warning. The data request specifies the snapshot years as a minimum set, so over-delivering 3D temperature is not non-compliance. This was argued over, because the annual time axis is treated the other way round — an extra year there is still an error — and the asymmetry is deliberate: the annual axis is pinned end to end by
experiments_ismip7.csv, so an extra year means the file does not match the experiment it names, whereas exceeding a stated minimum is a different act.A snapshot at nominal year 2000 then needs no special case and
TOLERATED_SNAPSHOT_YEARSis gone. It was accepted in silence, which told a modeller following the old README nothing at all. Reported as an unrequested snapshot it gets the honest answer: it is here, it was not asked for, it is not held against you. Issue #12 can settle it either way from there.An unexpected extra variable in a file is now a warning. The requested variable is present and fully checkable either way, and a reader taking it out of the file is unaffected by what sits beside it. What an extra variable says is that the file was probably not written for this submission — worth a look, not a fault.
A time coordinate that is not
float32is now a warning, split from the main-variable dtype check, which stays an error. Afloat64data variable is twice the size it should be for the archive and for everyone who has to move it. A time axis is one number per record, so the size argument that governs the data variable simply does not reach it.What is newly reported
Non-mandatory variables an experiment carries no files for. Nothing reported this before, so a group that meant to submit
litempand lost it in a script got no signal whatsoever. The design work here is in the saying, not the checking, because a deliberate omission is the common case — not every model supports every non-mandatory variable, GIA being the obvious one. So the warning says "carries no files for" rather than "missing", says it once per experiment naming all of them rather than once per variable (which would make a model with a narrow scope look far worse than one that dropped a single file), says out loud that this is expected if the model does not represent them, and stays out of the trailing naming-issues report, which is the part of the log that reads as a list of faults. It is scoped to the selected--variable-list, exactly as the mandatory-variable check is.An optional
not_modelled.txtin the source directory silences that warning for the variables it names — one name per line,#starting a comment so the reason can sit beside the name. Groups already submit a README explaining what they submitted and why some variables are absent, so the checker has no need to ask for that list again, and an explanatory list does not belong on a command line where it would have to be retyped for every run.Two rules keep it from becoming a way to hide problems, and both are errors rather than silent no-ops. A mandatory variable named in it is still a missing-mandatory error and the claim itself is an error: the list is a statement about optional variables, and a submission cannot opt out of the data request with it. A name that is not in the data request at all is an error too — typo or misunderstanding, both better said plainly than left to be inferred from a warning that did not go away. Whatever was declared is echoed into the log, so the archived record of a run shows what was claimed rather than merely that a warning did not appear. An absent file changes nothing.
The mechanism with no behaviour change
ISMIP7_variable_request.csvgains arange_severitycolumn, read by_load_criteriaand honoured by_check_numerical. Every shipped row iserror, so nothing about value ranges behaves differently today. This is the substance of issue #10, and the decision is that the classification is per variable rather than global: some variables have bounds a legitimate model can exceed and some do not, and there is no way to tell which from the checker's side.Switching a variable is then a one-cell, data-only diff that needs no code review and no reasoning about the checker, which is what makes the case-by-case triage cheap enough to actually happen and easy to revise as feedback comes in from groups. A blank cell, an unrecognised value and a missing column all mean
error, which is what makes the column safe to add now and fill in gradually. Region granularity (range_severity_ais/range_severity_gris) is a strictly larger version of the same design and can replace the single column later without touching the check.The one thing that affects existing automation
The checker now exits non-zero when there are errors, and zero when there are only warnings. It always exited 0 before, so nothing could gate on it. Two details beyond the obvious:
python -m isscheckerdiscardedmain()'s return value and now raisesSystemExit, matching the console script; and the paths where the checker gives up — directory not found, no.ncfiles, a dataset it could not read — return a summary whose error count is zero because nothing was checked, which on an error-based exit status would have exited 0 after checking nothing. Afatalflag tells that apart from a clean run.This is the last commit in the series precisely because it is the only one that changes how the tool behaves for existing automation, so it can be released, reverted or announced on its own.
How it was done
Severity has to reach both the log line and the counters, and there were roughly sixty call sites writing
" - ERROR: ..."and incrementing a localerrorsin parallel. Threading a second integer through every function and every caller would have doubled that bookkeeping and made every future severity change a two-line edit in two places.Instead a
Reporterowns the log file and the counts, so that the write is the count. Reporters nest:category()scopes findings to one of the reporting categories, andchild()opens a sub-total — one file's worth — that still rolls up into its parent, so a file's footer, an experiment's console line and the run-wide synthesis are sub-totals of one counting rather than three additions that have to agree. The_check_*functions no longer return counts at all; they say what they found at the severity they mean, and changing a classification is a one-word edit.The first two commits are that migration and are behaviour-preserving by construction: if the reporter emits the same strings, the reference log does not move, and the golden-log test proves it. Every commit through the demotions leaves the references untouched except where a change was intended, and each of those regenerates them in the same commit.
Two fixes that came with it
The companion-variable allowlist was too narrow.
_allowed_file_variablesunderstoodbounds,grid_mappingandcoordinatesbut notcell_measuresorancillary_variables, so a file carrying a cell area its requested variable is normalised by was reported as holding an unexpected variable. That is a false positive, not a soft finding, so it is removed outright rather than demoted — and before any severity change, so the two are not confused.The "closest requested name" hints were nondeterministic. The candidates came from a
set, so two equally close names resolved by iteration order and the same checker over the same files could produce different logs on different runs — which is the one thing this tool is not allowed to do. They are sorted now.Reporting changes
No warnings.orN warning(s). Please review.. A file with warnings and no errors still readsNo errors. Good job !on the line above.Mandatory variablestoVariable presence, which the non-mandatory warning otherwise contradicts.run_checkersummary dict — gainstotal_warnings, the per-category warning keys, andfatal.Testing
tests/test_reporter.pyis new: the line formats, the split between severities and categories, and the roll-up from a file's sub-total to the run's total, which is what the footer and the synthesis both rest on.Every demotion has a test asserting that the error count went down and the warning count went up, which is what would catch an accidental re-promotion. New tests cover the not-submitted warning and its scoping,
not_modelled.txtin all four of its cases,range_severity(driven by a synthetic criteria row, so the mechanism and the shipped data are tested independently), and the exit status including both fatal paths.A second reference log, generated with
include_non_mandatory=True, closes the gap PR #16 recorded:litempis the data request's onlyx,y,z,tvariable and so the only one whose snapshot axis the time checks look at, and it is not mandatory, so the whole snapshot branch had no golden coverage. Neither did the staticx,yrefgeoid. The existing reference keeps its meaning and is unchanged by that commit.CI now runs the whole
tests/directory. It namedtests/test_compliance_checker.pyexplicitly, sotest_golden_log.pynever actually ran there — despite its entire reason for existing being to catch a dependency release inside the supported ranges changing the log, which is what running it at both the floor and the latest environment is for.Deliberately not here
Filling in the per-variable range severities. The mechanism ships with every variable still an error; deciding which ones move is case-by-case work for after this, as data-only changes.