From 00a153ceb0dc460cdb4a80933e10c7287d2c3e9f Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Mon, 26 Oct 2026 09:53:35 +0000 Subject: [PATCH] Document configuration leaves and check comment coverage --- autofit/config/general.yaml | 4 +- autofit/config/logging.yaml | 18 +++--- autofit/config/notation.yaml | 36 +++++------ autofit/config/output.yaml | 10 ++-- scripts/check_config_comments.py | 90 ++++++++++++++++++++++++++++ test_autofit/test_config_comments.py | 60 +++++++++++++++++++ 6 files changed, 184 insertions(+), 34 deletions(-) create mode 100644 scripts/check_config_comments.py create mode 100644 test_autofit/test_config_comments.py diff --git a/autofit/config/general.yaml b/autofit/config/general.yaml index 07743f094..a0cf64428 100644 --- a/autofit/config/general.yaml +++ b/autofit/config/general.yaml @@ -30,7 +30,7 @@ profiling: repeats: 1 # The number of repeat function calls used to measure run-times when profiling. test: check_likelihood_function: true # if True, when a search is resumed the likelihood of a previous sample is recalculated to ensure it is consistent with the previous run. - exception_override: false + exception_override: false # If True, disable model assertions during likelihood evaluation. lh_timeout_seconds: # If a float is input, the log_likelihood_function call is timed out after this many seconds, to diagnose infinite loops. Default is None, meaning no timeout. log_likelihood_ceiling: # If a float is input, log likelihoods whose magnitude exceeds it are treated as numerical garbage and replaced by the resample figure of merit. Blank (null) or inf disables the guard, which is the default: a log likelihood scales with the noise-map units, so a fixed magnitude can reject a legitimate fit on a badly-scaled dataset. Opt in where the units are known (e.g. autolens_profiling sets 1.0e+20). - parallel_profile: false + parallel_profile: false # If True, save worker profiling statistics; also enable profiling.parallel_profile. diff --git a/autofit/config/logging.yaml b/autofit/config/logging.yaml index e7ec76f7b..0b05e7ed7 100644 --- a/autofit/config/logging.yaml +++ b/autofit/config/logging.yaml @@ -1,19 +1,19 @@ -version: 1 -disable_existing_loggers: false +version: 1 # Python logging dictionary schema version (1). +disable_existing_loggers: false # If True, disable existing non-root loggers when configuring logging. handlers: console: - class: logging.StreamHandler - level: INFO - stream: ext://sys.stdout - formatter: formatter + class: logging.StreamHandler # Python handler class used to emit log records. + level: INFO # Minimum logging severity emitted by this handler or logger. + stream: ext://sys.stdout # Output stream receiving console log records. + formatter: formatter # Name of the formatter applied by this handler. root: - level: INFO - handlers: [ console ] + level: INFO # Minimum logging severity emitted by this handler or logger. + handlers: [ console ] # Names of handlers receiving root logger records. formatters: formatter: - format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' # Python logging format string applied to each log record. total_files_open : false # Whether to output info on the total number of open files in the system, used for debugging parallelism issues. \ No newline at end of file diff --git a/autofit/config/notation.yaml b/autofit/config/notation.yaml index e0dd6cb5c..5ca995c8b 100644 --- a/autofit/config/notation.yaml +++ b/autofit/config/notation.yaml @@ -13,18 +13,18 @@ label: label: - centre: x - normalization: norm - parameter0: a - parameter1: b - parameter2: c - rate: \lambda - sigma: \sigma + centre: x # Plot label used for the centre parameter. + normalization: norm # Plot label used for the normalization parameter. + parameter0: a # Plot label used for the parameter0 parameter. + parameter1: b # Plot label used for the parameter1 parameter. + parameter2: c # Plot label used for the parameter2 parameter. + rate: \lambda # Plot label used for the rate parameter. + sigma: \sigma # Plot label used for the sigma parameter. superscript: - Exponential: e - Gaussian: g - ModelComponent0: M0 - ModelComponent1: M1 + Exponential: e # Class suffix for an unnamed Exponential Model; named components use component paths. + Gaussian: g # Class suffix for an unnamed Gaussian Model; named components use component paths. + ModelComponent0: M0 # Class suffix for an unnamed ModelComponent0 Model; named components use component paths. + ModelComponent1: M1 # Class suffix for an unnamed ModelComponent1 Model; named components use component paths. # label_format: The format certain parameters are output as in output files like the `model.results` file. @@ -32,10 +32,10 @@ label: label_format: format: - centre: '{:.2f}' - normalization: '{:.2f}' - parameter0: '{:.2f}' - parameter1: '{:.2f}' - parameter2: '{:.2f}' - rate: '{:.2f}' - sigma: '{:.2f}' + centre: '{:.2f}' # Python format string used for centre in result summaries. + normalization: '{:.2f}' # Python format string used for normalization in result summaries. + parameter0: '{:.2f}' # Python format string used for parameter0 in result summaries. + parameter1: '{:.2f}' # Python format string used for parameter1 in result summaries. + parameter2: '{:.2f}' # Python format string used for parameter2 in result summaries. + rate: '{:.2f}' # Python format string used for rate in result summaries. + sigma: '{:.2f}' # Python format string used for sigma in result summaries. diff --git a/autofit/config/output.yaml b/autofit/config/output.yaml index bbb2d7d48..e75fd2709 100644 --- a/autofit/config/output.yaml +++ b/autofit/config/output.yaml @@ -14,7 +14,7 @@ default: true # If true then files which are not explicitly listed here are outp # the `samples_summary.json` file. However, without a `samples.csv` file these types of tasks cannot be performed # after the fit is complete, for example via the database. -samples: true +samples: true # If True, retain the sampled parameter values, likelihoods and weights in samples.csv. # The `samples.csv` file contains every accepted sampled value of every free parameter with its log likelihood and # weight. For certain searches, the majority of samples have a very low weight and have no numerical impact on the @@ -31,7 +31,7 @@ samples: true # Set value to empty (e.g. delete 1.0e-10 below) to disable this feature. -samples_weight_threshold: 1.0e-10 +samples_weight_threshold: 1.0e-10 # Exclude samples with weights below this threshold from samples.csv; null disables filtering. ### Search Internal ### @@ -53,7 +53,7 @@ samples_weight_threshold: 1.0e-10 # every walker. This information is required to do certain analyes and make certain plots, therefore deleting the # folder means this information is list. -search_internal: false +search_internal: false # If False, delete the search internal checkpoint directory after the fit completes. ### Start Point ### @@ -62,7 +62,7 @@ search_internal: false # the visualizer wrapped in the Analysis class, and therefore should show things like the quality of the fit # to the data and the residuals at the start point. -start_point: true +start_point: true # If True, output visualization of the initializer start-point fit. ### Latent Variables ### @@ -108,4 +108,4 @@ model_graph: false # `model.graph`: graphical-model factor graph summary. Only m # Opt-in until the lens acceptance renders pass (model-figures epic phase 3). Unlike other keys this # one does NOT fall back to `default` when absent -- an absent key means off. -model_figure: false +model_figure: false # If True, output model.png beside model.info. diff --git a/scripts/check_config_comments.py b/scripts/check_config_comments.py new file mode 100644 index 000000000..caa3881b9 --- /dev/null +++ b/scripts/check_config_comments.py @@ -0,0 +1,90 @@ +"""Report config leaves lacking a comment on their line or immediately above. + +Run with one or more config directories; priors/ is excluded. Without arguments, +check this checkout's packaged autofit/config directory. Only YAML is loaded: +the scientific packages and their configuration stack are never imported. +""" + +import argparse +from pathlib import Path + +import yaml + + +def comment_lines(text): + """Find nonempty comments, masking YAML scalar tokens (including quoted #).""" + masked = list(text) + for token in yaml.scan(text): + if isinstance(token, yaml.tokens.ScalarToken): + for index in range(token.start_mark.index, token.end_mark.index): + if masked[index] != "\n": + masked[index] = " " + return { + index + for index, line in enumerate("".join(masked).splitlines()) + if "#" in line and line.split("#", 1)[1].strip() + } + + +def uncommented_leaves(path): + text = path.read_text() + comments = comment_lines(text) + lines = text.splitlines() + preceding_comments = { + line for line in comments if lines[line].lstrip().startswith("#") + } + findings = [] + + def walk(node, keys=()): + if isinstance(node, yaml.MappingNode): + for key, value in node.value: + path_keys = keys + (key.value,) + if isinstance(value, yaml.MappingNode): + walk(value, path_keys) + else: + line = key.start_mark.line + if line not in comments and line - 1 not in preceding_comments: + findings.append((path, line + 1, ".".join(path_keys))) + + for document in yaml.compose_all(text): + walk(document) + return findings + + +def check_roots(roots): + findings = [] + for root in roots: + root = Path(root) + if not root.is_dir(): + raise ValueError(f"Config directory does not exist: {root}") + files = sorted( + path + for path in root.rglob("*") + if path.is_file() + and path.suffix in {".yaml", ".yml"} + and "priors" not in path.relative_to(root).parts + ) + if not files: + raise ValueError(f"No non-prior YAML files found: {root}") + for path in files: + findings.extend(uncommented_leaves(path)) + return findings + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("roots", nargs="*", type=Path) + args = parser.parse_args(argv) + roots = args.roots or [Path(__file__).resolve().parents[1] / "autofit/config"] + try: + findings = check_roots(roots) + except (OSError, ValueError, yaml.YAMLError) as error: + parser.error(str(error)) + for path, line, key in findings: + print(f"{path}:{line}: {key}") + print(f"Uncommented config leaves: {len(findings)}") + return bool(findings) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test_autofit/test_config_comments.py b/test_autofit/test_config_comments.py new file mode 100644 index 000000000..1ea7f8b59 --- /dev/null +++ b/test_autofit/test_config_comments.py @@ -0,0 +1,60 @@ +"""The comment audit distinguishes YAML values from explanatory comments.""" + +import importlib.util +from pathlib import Path + +import pytest + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts/check_config_comments.py" +spec = importlib.util.spec_from_file_location("check_config_comments", SCRIPT) +audit = importlib.util.module_from_spec(spec) +spec.loader.exec_module(audit) + + +def test_yaml_comments_and_terminal_values(tmp_path): + path = tmp_path / "settings.yaml" + path.write_text( + 'quoted: "# literal"\n' + "single: '# literal' # Explanation.\n" + "empty_comment: true # \n" + "# Nullable setting.\n" + "nullable:\n" + "# Sequence setting.\n" + "sequence: [one, two]\n" + "block: |\n" + " # This is scalar content, not a comment.\n" + " fake_key: true\n" + "# Separated prose does not meet the adjacent-line rule.\n" + "\n" + "nested:\n" + " missing: false\n" + ) + assert [(line, key) for _, line, key in audit.check_roots([tmp_path])] == [ + (1, "quoted"), + (3, "empty_comment"), + (8, "block"), + (14, "nested.missing"), + ] + + +def test_priors_excluded_and_yml_included(tmp_path): + priors = tmp_path / "priors" + priors.mkdir() + (priors / "model.yaml").write_text("uncommented: true\n") + (tmp_path / "settings.yml").write_text("flag: true # Enable feature.\n") + assert audit.check_roots([tmp_path]) == [] + + +def test_missing_and_empty_roots_fail(tmp_path): + with pytest.raises(ValueError, match="does not exist"): + audit.check_roots([tmp_path / "missing"]) + with pytest.raises(ValueError, match="No non-prior YAML"): + audit.check_roots([tmp_path]) + + +def test_cli_exit_status_and_packaged_defaults(tmp_path, capsys): + assert audit.main([]) == 0 + (tmp_path / "settings.yaml").write_text("flag: true\n") + assert audit.main([str(tmp_path)]) == 1 + assert "settings.yaml:1: flag" in capsys.readouterr().out