Skip to content

Commit b6a9117

Browse files
Staacksclaude
andcommitted
iOS conforms on duplicate metadata; validators learn the at-most-once rule
The iOS development branch now tolerates duplicated root metadata with last-wins semantics (efff512c), scoped to the six metadata children - every implementation agrees, so the duplicate-root-elements entry is deleted per the mechanism, and the duplicate-metadata-last-wins rule's state records the fix. The constraint itself becomes checkable: the grammar cannot express at-most-once (root children are folded into a repeated choice for libxml2), so the Schematron gains the root-metadata-once pattern and validate_experiments a check_root_once pass, both flagging a duplicated title, state-title, category, icon, color or description. A new corpus fixture (invalid/state-title-duplicate.phyphox, parser: accepts) pins both sides: validators flag it, the app suites assert it loads. Also document the unattended-launch seam next to phyphox://asset=: iOS takes -phyphoxUrl as a launch argument (simctl/devicectl) because the openurl confirmation dialog cannot be suppressed; Android deliberately has no counterpart - adb shell am start is dialog-free. Recorded so nobody goes looking for the missing half. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a9c674d commit b6a9117

11 files changed

Lines changed: 111 additions & 70 deletions

corpus/README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ format surface that the shipped experiments alone never touch.
1919
`gcd`/`lcm`, `butterworth`, the `events` block of saved states, `appleBan`,
2020
and a set of rarely used view, network and I/O attributes. Each file states
2121
its purpose in a comment.
22-
- `invalid/` — real files carrying a documented defect each: dead draft
23-
syntax that never shipped (`mode="trigger"` on a network connection, the
24-
pre-BLE Bluetooth syntax, the 2018 device-specific conversion draft,
25-
dev-era camera attributes, `precisionX`), typos (`cycle=` for `cycles=`,
26-
`site=` for `size=`), and attributes applied to the wrong element.
22+
- `invalid/` — files carrying a documented defect each, nearly all
23+
collected from real use: dead draft syntax that never shipped
24+
(`mode="trigger"` on a network connection, the pre-BLE Bluetooth syntax,
25+
the 2018 device-specific conversion draft, dev-era camera attributes,
26+
`precisionX`), typos (`cycle=` for `cycles=`, `site=` for `size=`),
27+
attributes applied to the wrong element, and a duplicated `state-title`
28+
in the shape old Android re-saves wrote.
2729
`expected.yml` records the findings each file must produce and what the
2830
app parsers do with it (`parser: rejects|accepts` — see its header);
2931
names are flattened to the defect, so file names carry no trace of where
@@ -76,8 +78,9 @@ Both apps run this corpus in their own test suites (test-matrix rows
7678
do splits per the unknown-attribute-ignored rule, recorded as `parser:`
7779
in `expected.yml`: files marked `rejects` must fail to load (any error;
7880
message texts are platform wording and never asserted), files marked
79-
`accepts` must LOAD — their only defects are unknown or misapplied
80-
attributes, which the parsers ignore for compatibility, and that
81+
`accepts` must LOAD — their defects are tolerated for compatibility:
82+
unknown or misapplied attributes (which the parsers ignore) or
83+
duplicated root metadata (where the last occurrence wins), and that
8184
tolerance is itself contract worth pinning. The classification was
8285
measured on the Android development branch (2026-08-24); an app runner
8386
disagreeing with it is a finding to report, not to code around.

corpus/invalid/expected.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
# rejects - the real loading path refuses the file (structural
1717
# defects, invalid enum values, unknown conversion
1818
# functions, the graph pairing rules)
19-
# accepts - the file LOADS: its only defects are unknown or
20-
# misapplied attributes, which parsers ignore for
21-
# compatibility. The app suites assert the load
22-
# succeeds - that tolerance is itself contract.
19+
# accepts - the file LOADS: its defects are tolerated for
20+
# compatibility - unknown or misapplied attributes
21+
# (unknown-attribute-ignored) or duplicated root
22+
# metadata (duplicate-metadata-last-wins). The app
23+
# suites assert the load succeeds - that tolerance
24+
# is itself contract.
2325
# Classification measured on the Android development branch
2426
# 2026-08-24 (corpus runner); iOS confirms with its own runner
2527
# and any disagreement is a finding to report, not to paper over.
@@ -106,6 +108,12 @@ analysis-optimization.phyphox: # stale attribute no parser ever read
106108
parser: accepts
107109
findings:
108110
- '<analysis>: optimization='
111+
state-title-duplicate.phyphox: # old Android re-saves appended a fresh
112+
parser: accepts # state-title instead of replacing it;
113+
findings: # parsers tolerate the duplicate, last
114+
- 'state-title appears 2 times' # occurrence wins (rule
115+
# duplicate-metadata-last-wins,
116+
# decided 2026-08-24)
109117
graph-unused-x.phyphox: # x inputs no y input uses (decided 2026-08-20)
110118
parser: rejects
111119
findings:
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<phyphox xmlns="http://phyphox.org/xml" version="1.5" locale="en">
2+
<title>Pendulum</title>
3+
<state-title>Measurement 1/23/18 21:13</state-title>
4+
<state-title>Pendulum on the swing</state-title>
5+
<category>Saved states</category>
6+
<description>
7+
A saved state whose re-save appended a second state-title instead of
8+
replacing the first, the shape old Android versions wrote (and the
9+
current writer can still produce, see the live-NodeList removal skip
10+
in PhyphoxExperiment.java). Parsers tolerate the duplicate and the
11+
last occurrence wins (rule duplicate-metadata-last-wins); writing
12+
one remains an error, which is what the validators flag here.
13+
</description>
14+
<data-containers>
15+
<container size="0">t</container>
16+
<container size="0">x</container>
17+
</data-containers>
18+
<views>
19+
<view label="Data">
20+
<graph label="x(t)" labelX="t" labelY="x">
21+
<input axis="x">t</input>
22+
<input axis="y">x</input>
23+
</graph>
24+
</view>
25+
</views>
26+
</phyphox>

docs/file-format/index.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ The title of the experiment. This is just a simple string. Try to keep it short
3535

3636
This should not be used for an experiment which will be distributed. This tag contains the title given by the user when saving the state of an experiment. If this is set, the app will show this experiment in the saved-states section. A file may carry at most one `state-title` — like the other metadata tags of the root element, it must not be repeated. If a legacy file nevertheless carries a duplicate (old versions of the app could write one when re-saving a state), the app tolerates it and the last occurrence wins.
3737

38-
{{inconsistency:duplicate-root-elements}}
39-
4038
{{spec:root/phyphox/state-title}}
4139

4240
### Tag: category

docs/transferring-experiments.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ phyphox://asset=bluetooth%2Fphyphox_m_bmp581.phyphox
4545

4646
This is meant for links in worksheets, documentation and classroom materials that should land the user directly in the right bundled experiment, and it is what automated testing uses to drive the app. It is deliberately limited to the bundled collection: experiment titles are not unique, and files elsewhere on the device keep going through the regular file-opening routes. An unknown path shows the app's normal "could not load" message.
4747

48+
#### Launching from automation
49+
50+
Scripted testing needs to open such a URL without anyone tapping a dialog, and the two platforms get there differently — deliberately, so do not look for the missing half:
51+
52+
- **Android needs no special mechanism.** `adb shell am start -a android.intent.action.VIEW -d "phyphox://asset=accelerometer.phyphox"` delivers the URL to the app without any confirmation dialog.
53+
- **iOS confirms opening a URL from `simctl openurl` with a system dialog that cannot be suppressed**, so the app instead accepts the URL as a launch argument: `-phyphoxUrl <url>` behaves exactly as if the URL had been opened from outside the app, and goes through the same handler, so the semantics cannot drift (AppDelegate.swift).
54+
55+
```
56+
xcrun simctl launch <udid> de.rwth-aachen.physics.phyphox -phyphoxUrl "phyphox://asset=accelerometer.phyphox"
57+
xcrun devicectl device process launch --device <udid> de.rwth-aachen.physics.phyphox -phyphoxUrl "phyphox://asset=accelerometer.phyphox"
58+
```
59+
4860
### Online QR-Codes (recommended)
4961

5062
If your audience has internet access, this is the recommended method and the QR code can be created directly from our editor. This method is very similar to the phyphox:// URL, but the URL is encoded into a QR code, which can simply be scanned from the phyphox main menu. If you want to or need to create the QR code yourself, simply create a QR-Code that contains a link starting with phyphox://, <http://> or <https://> and phyphox should simply download the experiment after scanning the QR code.

inconsistencies.yml

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -89,44 +89,3 @@
8989
A non-power-of-two number of input samples yields a different output
9090
length and different values on the two platforms; a power-of-two input
9191
gives identical results on both.
92-
93-
- id: duplicate-root-elements
94-
title: Duplicated metadata elements in a saved state
95-
area: file-format
96-
status: decided
97-
canonical: >-
98-
Tolerated, last occurrence wins (rule duplicate-metadata-last-wins in
99-
spec/rules.yml, decided 2026-08-24): a repeated metadata child of the
100-
root element does not reject the file, and the last occurrence is
101-
used. Android's behavior is canonical; iOS's rejection is the bug.
102-
Writers must still emit exactly one - the tolerance exists for legacy
103-
saved states, not for new files.
104-
affects:
105-
android: >-
106-
Last occurrence wins: the parser overwrites the previously read value
107-
for each repeated metadata child (PhyphoxFile.java,
108-
phyphoxBlockParser, e.g. case "state-title" assigns
109-
experiment.stateTitle unconditionally), so a file with two
110-
state-title elements loads and shows the second.
111-
ios: >-
112-
Rejects the file: every single-slot child handler throws
113-
ElementHandlerError.duplicateElement on a second occurrence
114-
(Serialization/ElementHandler.swift infrastructure, reached via
115-
PhyphoxElementHandler's stateTitleHandler and the other metadata
116-
handlers).
117-
verified: >-
118-
iOS behavior observed 2026-08-24 by the corpus runner
119-
(CorpusConformanceTests, commit f393ae8c) on the original
120-
taipei101_down fixture; Android's runner loaded the same file the same
121-
day.
122-
summary: >-
123-
The format allows each metadata child of the root element - state-title,
124-
title, category and the like - at most once (ruled 2026-08-24), but
125-
files with a duplicated state-title exist in the wild: old Android
126-
versions appended a new state-title on re-saving a state without
127-
removing the previous one, and the current Android save path can still
128-
leave a stale element behind (live-NodeList removal skip in
129-
PhyphoxExperiment.java). Android loads such files, last occurrence
130-
winning; iOS refuses them, so a legacy recording can be unreadable on
131-
iOS only - the compatibility break the last-wins ruling exists to
132-
prevent.

spec/rules.yml

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -338,19 +338,21 @@ rules:
338338
that produces the duplicates is a separate fix (handed off
339339
2026-08-24).
340340
ios: >-
341-
Does not conform: every single-slot child handler throws
342-
ElementHandlerError.duplicateElement on a second occurrence
343-
(Serialization/ElementHandler.swift infrastructure), rejecting the
344-
file. Recorded as duplicate-root-elements in inconsistencies.yml
345-
until fixed.
341+
Conforms as of the development branch (efff512c, 2026-08-24): the
342+
six root metadata handlers read lastResult() instead of
343+
expectOptionalResult() (DocumentParser+Extensions.swift,
344+
PhyphoxElementHandler.swift), scoped so that a duplicate of any
345+
other element stays an ElementHandlerError.duplicateElement, with
346+
tests pinning both directions.
346347
check: >-
347-
The published validators cannot flag a duplicate today: the grammar
348-
deliberately folds the root's children into a repeated choice
349-
((a | b | ...)*) to sidestep libxml2's interleave blowup, and neither
350-
the Schematron nor validate_experiments checks cardinality yet. A
351-
corpus fixture pinning last-wins should follow once one of them does
352-
- and once iOS conforms, so its runner can assert the load rather
353-
than record a divergence.
348+
The grammar cannot express the at-most-once constraint (the root's
349+
children are deliberately folded into a repeated choice to sidestep
350+
libxml2's interleave blowup), so the Schematron carries it (pattern
351+
root-metadata-once) and validate_experiments checks it
352+
(check_root_once). The corpus fixture
353+
invalid/state-title-duplicate.phyphox pins both sides: the
354+
validators flag it, the app suites assert it loads
355+
(parser: accepts).
354356
applies_to: every element name in the format
355357
state:
356358
android: >-

test-matrix.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@ tests:
6060
status: active
6161
description: >-
6262
Every corpus/invalid file whose expected.yml entry says
63-
`parser: accepts` LOADS successfully: its only defects are unknown or
64-
misapplied attributes, which the parsers ignore per the
65-
unknown-attribute-ignored rule (spec/rules.yml). This pins the
63+
`parser: accepts` LOADS successfully: its defects are tolerated for
64+
compatibility per the unknown-attribute-ignored and
65+
duplicate-metadata-last-wins rules (spec/rules.yml). This pins the
6666
compatibility guarantee - a parser that starts rejecting unknown
67-
attributes breaks files in the wild.
67+
attributes or legacy duplicates breaks files in the wild.
6868
6969
- id: corpus-version-gate
7070
area: file-format

tools/generate_validators.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,21 @@ def assert_(r, test, text, warning=False):
657657
"normalize-space(current())]) = 1",
658658
"Data container names must be unique.")
659659

660+
# --- root metadata appears at most once ------------------------------
661+
# The grammar cannot express this: the root's children are deliberately
662+
# folded into one repeated choice to sidestep libxml2's interleave
663+
# blowup. Rule duplicate-metadata-last-wins (spec/rules.yml): apps
664+
# tolerate a legacy duplicate - the last occurrence wins - but nothing
665+
# may write one, so a duplicate is an authoring error.
666+
p = pattern("root-metadata-once")
667+
for name in ("title", "state-title", "category", "icon", "color",
668+
"description"):
669+
r = rule(p, f"/{ln('phyphox')}/{ln(name)}")
670+
assert_(r, f"count(../{ln(name)}) = 1",
671+
f"The {name} element may appear at most once; apps "
672+
"tolerate a legacy duplicate (the last occurrence wins) "
673+
"but nothing may write one.")
674+
660675
# --- graph dataset pairing (decided and amended 2026-08-20) ----------
661676
p = pattern("graph-dataset-pairing")
662677
ax = lambda v: ("translate(@axis,'ABCDEFGHIJKLMNOPQRSTUVWXYZ',"

tools/hooks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ def _check_corpus():
424424
# unknown camera component can never be matched here (gap found
425425
# 2026-08-24 when exactly that expectation failed)
426426
ve.check_slots(root, slots, components, rep, n)
427+
ve.check_root_once(root, rep, n)
427428
details = [f"{kind}: {d}" for kind, lst in rep.items.items()
428429
for _, d in lst]
429430
if not details:

0 commit comments

Comments
 (0)