# SPLASH validation contribution: reproducible first-ten baseline

This read-only community contribution supplies a small baseline for the existing “first 10 instruments sorted by name” discussion. It does not test an AI model or change the SPLASH catalogue.

## Run

Python 3.9 or later; standard library only.

```sh
python -m unittest -v test_splash_audit.py
python splash_audit.py snapshot.json --output audit.json
```

Obtain the current public JSON from:
https://visualization.osdr.nasa.gov/splash-api/public/instruments

The local audit used a snapshot retrieved 2026-10-30T15:19:58.361Z. Full raw snapshot is kept locally; this review bundle contains the code, tests, and a small result summary. A later download may differ, so compare the SHA-256 before comparing results.

## Snapshot and checks

- SHA-256: `b069d0dba999d137183c75f38b9f070423115e6088c358d697a35a3346e86c35`
- 148 JSON rows: one recognized field-label row plus 147 instruments.
- The field-label row matches `_id=0`, `name="Instrument/Platform Name"`, `background="Background/Purpose"`, and `mass="Mass"`. ID zero alone is never enough to exclude a record.
- 147 unique non-null IDs; 0 missing IDs; 0 duplicated IDs; 0 missing names.
- 0 normalized-name collision groups. Collisions would be reported for review without merging records.
- 16 regression tests pass, including the first-ten boundary, a real zero ID, typed IDs, numeric launch years, placeholder names and shuffled duplicate tie-breaks.

## Candidate ordering

Apply NFKC, trim surrounding whitespace, then Unicode casefold to the name. Break ties with the original numeric ID ascending (non-integer IDs retain a typed JSON key), then raw name and canonical full-record JSON. Raw displayed names are unchanged. This is a documented candidate convention for review, not an official SPLASH or scientific standard; punctuation/aliases are not collapsed.

## First ten under that convention

1. ID 64: Advanced Biological Research System
2. ID 34: Advanced Plant Habitat
3. ID 65: Advanced Protein Crystallization Facility
4. ID 46: Advanced Space Experiment Processor
5. ID 66: ADvanced Space Experiment Processor-2
6. ID 67: ADvanced Space Experiment Processor-4
7. ID 97: Analytical Containment Transfer Tool
8. ID 139: Animal Access Unit
9. ID 68: Animal Enclosure Module
10. ID 145: Aquatic Animal Experiment Unit

## Compare with the visible Name-ascending order

The separately observed browser first-ten list starts with `ADvanced Space Experiment Processor-2` and `ADvanced Space Experiment Processor-4`, ahead of `Advanced Biological Research System`. The casefold candidate above puts `Advanced Biological Research System` first. The script therefore also outputs `raw_name_first_ten` using a case-sensitive Unicode-codepoint comparison. For this snapshot, that comparator matches the observed first-ten list below; this does not establish the full UI sorting algorithm or mean either convention is a catalogue defect. Evaluation should agree which order is intended before scoring an AI answer.

1. ID 66: ADvanced Space Experiment Processor-2
2. ID 67: ADvanced Space Experiment Processor-4
3. ID 64: Advanced Biological Research System
4. ID 34: Advanced Plant Habitat
5. ID 65: Advanced Protein Crystallization Facility
6. ID 46: Advanced Space Experiment Processor
7. ID 97: Analytical Containment Transfer Tool
8. ID 139: Animal Access Unit
9. ID 68: Animal Enclosure Module
10. ID 145: Aquatic Animal Experiment Unit

## Scope

The collection URL plus original ID is the source locator. No unverified per-record website URL is generated. Missingness uses declared lexical tokens and does not prove a present value is correct. Numeric fields, units, capability values and Maybe/unknown are not automatically normalized or converted. Catalogue ordering agreement and model evaluation remain follow-up work.

## Attribution

Source: NASA Open Science Data Repository (OSDR), SPLASH; supported by NASA Biological and Physical Sciences.
https://visualization.osdr.nasa.gov/splash/

## Original discussion

https://awg.osdr.space/t/solving-ai-accessibility-of-splash/3929


## Snapshot provenance

```json
{
  "source_url": "https://visualization.osdr.nasa.gov/splash-api/public/instruments",
  "retrieved_at_utc": "2026-10-30T15:19:58.361Z",
  "retrieval_time_basis": "local file write completion immediately after successful HTTP200 fetch",
  "sha256": "b069d0dba999d137183c75f38b9f070423115e6088c358d697a35a3346e86c35",
  "byte_length": 298482,
  "raw_rows": 148,
  "header_row_index": [
    0
  ],
  "instrument_records": 147,
  "fetch_method": "one read-only HTTP GET; no account or restricted dataset",
  "record_locator": "collection source_url plus original _id",
  "full_snapshot_public_bundle": false
}
```

## Complete source files

Save the following two blocks as the named UTF-8 files in the same directory. Download a fresh snapshot from the source URL and run the commands above; use the recorded snapshot hash when reproducing the result shown here.

### splash_audit.py

<!-- BEGIN FILE splash_audit.py -->
```python
"""Read-only SPLASH snapshot audit; Python standard library only.

This is a community candidate convention, not an official SPLASH sort order.
The original record values are preserved. No record is merged or amended.
"""
import argparse
import collections
import hashlib
import json
import pathlib
import unicodedata

SOURCE_URL = "https://visualization.osdr.nasa.gov/splash-api/public/instruments"
MISSING_TOKENS = {"", "n/a", "na", "tbd", "unknown", "none", "null"}
FIELDS = ("name", "background", "category", "operations", "status",
          "date_of_first_launch", "mass", "power", "data_rate", "sources",
          "study_links", "schematics", "interface_definitions")


def schema_row(record):
    return (record.get("_id") == 0 and record.get("name") == "Instrument/Platform Name"
            and record.get("background") == "Background/Purpose"
            and record.get("mass") == "Mass")


def text_key(value):
    return unicodedata.normalize("NFKC", value).strip().casefold()


def is_missing(value):
    if value is None:
        return True
    if isinstance(value, str):
        return value.strip().casefold() in MISSING_TOKENS
    if isinstance(value, (list, dict)):
        return not value
    return False  # Numeric years and zero are values, not absent strings.


def id_key(value):
    # Preserve JSON identifier types: the string "1" is not silently ID 1.
    return json.dumps(value, ensure_ascii=False, sort_keys=True)


def audit(raw):
    data = json.loads(raw)
    if not isinstance(data, list) or any(not isinstance(r, dict) for r in data):
        raise ValueError("Expected a JSON list of record objects")
    headers = [i for i, r in enumerate(data) if schema_row(r)]
    records = [r for r in data if not schema_row(r)]
    identifiers = [id_key(r.get("_id")) for r in records if r.get("_id") is not None]
    counts = collections.Counter(identifiers)
    duplicate_ids = [json.loads(k) for k, n in sorted(counts.items()) if n > 1]
    names = [r for r in records if isinstance(r.get("name"), str) and not is_missing(r["name"])]
    # NFKC + whitespace trim + Unicode casefold; no punctuation stripping or alias merge.
    # Numeric IDs are ordered numerically; all other IDs retain a typed JSON key.
    def sort_key(record):
        ident = record.get("_id")
        tie = (0, ident) if type(ident) is int else (1, id_key(ident))
        return (text_key(record["name"]), tie, record["name"],
                json.dumps(record, ensure_ascii=False, sort_keys=True))
    ordered = sorted(names, key=sort_key)
    raw_ordered = sorted(names, key=lambda r: (r["name"], sort_key(r)[1:]))
    name_groups = collections.defaultdict(list)
    for r in names:
        name_groups[text_key(r["name"])].append(r.get("_id"))
    collisions = [{"normalized_name": k, "ids": v}
                  for k, v in sorted(name_groups.items()) if len(v) > 1]
    fill = {f: {"non_missing": sum(not is_missing(r.get(f)) for r in records),
                "missing": sum(is_missing(r.get(f)) for r in records)} for f in FIELDS}
    # These fields are reported as raw unique labels, not canonicalized automatically.
    vocabularies = {f: sorted({str(r[f]) for r in records if not is_missing(r.get(f))})
                    for f in ("category", "operations", "status")}
    return {"source_url": SOURCE_URL,
            "snapshot_sha256": hashlib.sha256(raw).hexdigest(),
            "raw_rows": len(data), "schema_row_indexes": headers,
            "instrument_records": len(records), "unique_present_ids": len(counts),
            "missing_ids": sum(r.get("_id") is None for r in records),
            "duplicate_ids": duplicate_ids,
            "missing_names": len(records) - len(names),
            "normalized_name_collisions": collisions,
            "sort_convention": "candidate: NFKC + trim + casefold(name); integer ID ascending tie-break, otherwise typed JSON ID; raw name and canonical JSON final tie-break; no alias/punctuation merge",
            "first_ten": [{"_id": r.get("_id"), "name": r["name"]} for r in ordered[:10]],
            "raw_name_sort_convention": "comparison only: raw name case-sensitive Unicode codepoint ordering, with the same typed-ID/raw-record tie-break; not a claim about the entire UI sorting algorithm",
            "raw_name_first_ten": [{"_id": r.get("_id"), "name": r["name"]} for r in raw_ordered[:10]],
            "fill_counts": fill, "raw_vocabularies": vocabularies,
            "limits": ["Not an official catalogue correction or approved sorting convention.",
                       "Missingness is a declared lexical rule; present values are not independently validated.",
                       "No per-record web URL is invented; use source_url plus _id to locate the original record.",
                       "Duplicate/normalized-name collisions are review candidates; records are never merged."]}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("snapshot", type=pathlib.Path)
    parser.add_argument("--output", type=pathlib.Path)
    args = parser.parse_args()
    result = audit(args.snapshot.read_bytes())
    rendered = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
    if args.output:
        args.output.write_text(rendered, encoding="utf-8")
    else:
        print(rendered, end="")


if __name__ == "__main__":
    main()
```
<!-- END FILE splash_audit.py -->

### test_splash_audit.py

<!-- BEGIN FILE test_splash_audit.py -->
```python
import json
import unittest
from splash_audit import audit, is_missing


def check(rows):
    return audit(json.dumps(rows, ensure_ascii=False).encode("utf-8"))


class SnapshotAuditTests(unittest.TestCase):
    def test_header_is_excluded_from_instrument_count_and_order(self):
        result = check([{"_id": 0, "name": "Instrument/Platform Name", "background": "Background/Purpose", "mass": "Mass"},
                        {"_id": 3, "name": "Zeta"}])
        self.assertEqual(result["raw_rows"], 2)
        self.assertEqual(result["instrument_records"], 1)
        self.assertEqual(result["schema_row_indexes"], [0])
        self.assertEqual(result["first_ten"], [{"_id": 3, "name": "Zeta"}])

    def test_zero_identifier_is_not_assumed_to_be_header(self):
        self.assertEqual(check([{"_id": 0, "name": "Real instrument"}])["instrument_records"], 1)

    def test_first_ten_boundary_excludes_header_and_eleventh_instrument(self):
        rows = [{"_id": n, "name": f"Instrument {n:02d}"} for n in range(11, 0, -1)]
        self.assertEqual([r["_id"] for r in check(rows)["first_ten"]], list(range(1, 11)))

    def test_case_and_surrounding_space_do_not_change_order(self):
        result = check([{"_id": 2, "name": " beta "}, {"_id": 1, "name": "ALPHA"}])
        self.assertEqual([r["_id"] for r in result["first_ten"]], [1, 2])

    def test_raw_case_sensitive_comparison_is_separate_from_casefold_candidate(self):
        result = check([{"_id": 1, "name": "Advanced A"}, {"_id": 2, "name": "ADvanced Z"}])
        self.assertEqual([r["_id"] for r in result["first_ten"]], [1, 2])
        self.assertEqual([r["_id"] for r in result["raw_name_first_ten"]], [2, 1])

    def test_equal_normalized_name_has_numeric_id_tie_break(self):
        rows = [{"_id": 10, "name": "ALPHA"}, {"_id": 2, "name": "alpha"}]
        self.assertEqual([r["_id"] for r in check(rows)["first_ten"]], [2, 10])
        self.assertEqual(len(check(rows)["normalized_name_collisions"]), 1)

    def test_duplicate_ids_are_reported_but_not_removed(self):
        result = check([{"_id": 1, "name": "A"}, {"_id": 1, "name": "B"}])
        self.assertEqual(result["duplicate_ids"], [1])
        self.assertEqual(result["instrument_records"], 2)

    def test_string_id_is_not_conflated_with_integer(self):
        result = check([{"_id": "1", "name": "A"}, {"_id": 1, "name": "B"}])
        self.assertEqual(result["unique_present_ids"], 2)
        self.assertEqual(result["duplicate_ids"], [])

    def test_missing_names_are_flagged_and_not_sorted_as_empty(self):
        result = check([{"_id": 1, "name": " "}, {"_id": 2}, {"_id": 3, "name": "C"}])
        self.assertEqual(result["missing_names"], 2)
        self.assertEqual(len(result["first_ten"]), 1)

    def test_placeholder_names_use_the_same_declared_missing_rule(self):
        rows = [{"_id": i, "name": value} for i, value in enumerate(["N/A", "TBD", "Unknown", "Real name"], 1)]
        result = check(rows)
        self.assertEqual(result["missing_names"], 3)
        self.assertEqual(result["fill_counts"]["name"]["missing"], 3)
        self.assertEqual(result["first_ten"], [{"_id": 4, "name": "Real name"}])

    def test_ambiguous_duplicate_id_and_normalized_name_have_stable_raw_tie_break(self):
        rows = [{"_id": 1, "name": "alpha"}, {"_id": 1, "name": "ALPHA"},
                {"_id": 1, "name": " alpha "}]
        self.assertEqual(check(rows)["first_ten"], check(list(reversed(rows)))["first_ten"])

    def test_numeric_year_is_present(self):
        result = check([{"_id": 1, "name": "A", "date_of_first_launch": 2017}])
        self.assertEqual(result["fill_counts"]["date_of_first_launch"]["non_missing"], 1)

    def test_zero_and_false_are_not_marked_missing(self):
        self.assertFalse(is_missing(0))
        self.assertFalse(is_missing(False))

    def test_declared_missing_tokens_are_flagged(self):
        for value in (None, "", "  ", "N/A", "tbd", "Unknown", [], {}):
            self.assertTrue(is_missing(value), value)

    def test_input_is_not_mutated_or_aliases_merged(self):
        rows = [{"_id": 1, "name": "Alpha-Beta"}, {"_id": 2, "name": "Alpha Beta"}]
        before = json.dumps(rows)
        result = check(rows)
        self.assertEqual(json.dumps(rows), before)
        self.assertEqual(len(result["first_ten"]), 2)
        self.assertEqual(result["normalized_name_collisions"], [])

    def test_invalid_container_rejected(self):
        for value in ({"records": []}, [1], [None]):
            with self.assertRaises(ValueError):
                check(value)


if __name__ == "__main__":
    unittest.main()
```
<!-- END FILE test_splash_audit.py -->

