Skip to content

Commit b48c521

Browse files
Staacksclaude
andcommitted
Check spec completeness against the iOS handlers, and fix the audio structure
The blocks were modelled by reading both parsers, which is the process that has been wrong repeatedly. tools/spec_vs_ios.py checks the result independently: iOS declares its surface plainly, an enum Attribute per handler with children registered by name, so walking that from the root gives an inventory that does not depend on my having read the right lines. It found one real structural error. The audio output models <input> at two levels: directly under <audio> it is the waveform, and inside <tone> or <noise> it drives one parameter of that generator. The spec had modelled input only under audio, conflating all three. Corrected, with frequency recorded as applying to tone alone. Everything else it reported was my extraction, and each taught the tool something: class bodies must be brace-matched or a regex runs into the next class; class names are not unique, since OutputElementHandler.swift declares its own private AudioElementHandler and BluetoothElementHandler; and childHandlers maps names to variables rather than classes, which until resolved left the walk reaching 16 pairs instead of 75. The same sweep on the Android side, over every getXAttribute in the root, translations, export, events, data-containers and network parsers, reported nothing missing. Five attributes are declared by an iOS handler and never read - stride on a bluetooth output, replacement on a dropdown map, clear on the audio generator inputs, mtu, and a copy of its child's enum on events. Listed in the tool rather than modelled: in the source, but not part of the format. Also notes that iOS accepts any events child matching a TimeMappingEvent, including clear, where Android takes the documented start and pause. No file should contain a clear event, so this is looseness rather than a divergence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fbff57b commit b48c521

4 files changed

Lines changed: 281 additions & 16 deletions

File tree

‎spec/FINDINGS.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,48 @@ over the API.** The entry has been corrected to say so.
506506
That is the strongest argument yet for the cross-check existing. Reading two parsers tells you
507507
what they do; only a third source tells you what is missing from both readings.
508508

509+
### Checking completeness instead of asserting it
510+
511+
The blocks were modelled by reading both parsers, which is exactly the process that has been
512+
wrong repeatedly. `tools/spec_vs_ios.py` now checks the result independently: iOS declares its
513+
accepted surface unusually plainly — an `enum Attribute` per handler, children registered by
514+
name — so walking that from the root element gives an inventory of the format that does not
515+
depend on my having read the right lines.
516+
517+
It found one **real structural error**: `<input>` under `<tone>` and `<noise>` in the audio
518+
output. The spec had modelled `input` only under `<audio>`, conflating the direct waveform
519+
source (no `parameter`) with the generator parameter inputs (`parameter`, `type`) — three
520+
elements collapsed into one. Corrected, and `frequency` is now recorded as applying to `tone`
521+
only, which `noise` does not accept.
522+
523+
Everything else it reported was my own extraction, and each false finding taught the tool
524+
something:
525+
526+
- **Class bodies must be brace-matched.** A regex ending at the first `\n}` runs past the end
527+
of a class into the next. That produced `experimentTime` on `<events>` and `stride` on a
528+
bluetooth output, both belonging to neighbouring classes.
529+
- **Class names are not unique.** `OutputElementHandler.swift` declares its *own* private
530+
`AudioElementHandler` and `BluetoothElementHandler`, with the same names as the input
531+
block's. Merging them made the output audio appear to take `append` and the output bluetooth
532+
to take `mode`, `rate` and `subscribeOnStart`. Keys are now file-qualified.
533+
- **`childHandlers` maps names to variables, not classes**, which has to be resolved through
534+
the `let x = SomeHandler()` bindings — until that was handled the walk reached 16 pairs
535+
instead of 75.
536+
537+
The equivalent sweep on the Android side — every `getXAttribute` call in the root,
538+
translations, export, events, data-containers and network parsers, diffed against the spec —
539+
reported nothing missing.
540+
541+
Five attributes are declared by an iOS handler and never read: `stride` on a bluetooth output,
542+
`replacement` on a dropdown map, `clear` on the audio generator inputs, `mtu`, and a copy of
543+
its child's enum on `<events>`. They are listed in the tool rather than modelled — in the
544+
source, but not part of the format.
545+
546+
One piece of latitude worth knowing: iOS accepts any `<events>` child whose name matches a
547+
`TimeMappingEvent`, which includes `<clear>`, where Android switches on the documented `start`
548+
and `pause`. No file should contain a clear event, since clearing empties the list before it
549+
can be written, so this is looseness rather than a divergence.
550+
509551
### Revised projection
510552

511553
All six blocks are modelled: 128 elements, 302 attributes and 194 named analysis slots, leaving roughly 80,

‎spec/output.yml‎

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,23 +55,20 @@ elements:
5555
summary: Normalise the amplitude of all inputs.
5656
agreement: agreed
5757

58+
# <input> appears at two levels and means different things. Directly under
59+
# <audio> it is the waveform itself; inside <tone> or <noise> it drives one
60+
# parameter of that generator. Android handles all three with the same code,
61+
# distinguishing them by whether `parameter` is set and whether a generator is
62+
# open; iOS registers a separate handler under each.
5863
- name: input
5964
parent: audio
6065
since: "1.0"
6166
summary: >-
62-
Feeds a data container or a constant into the audio output, either as the
63-
waveform itself or as a parameter of the tone or noise generator preceding
64-
it.
65-
text: Name of a data container, or a literal number when type is value.
67+
The waveform, written to the speaker at the end of each analysis pass.
68+
Values run from -1 to +1 and the buffer's length sets the duration. Only
69+
one direct source is supported.
70+
text: Name of the data container holding the waveform.
6671
attributes:
67-
- name: parameter
68-
type: enum
69-
values: [pan, amplitude, duration, frequency]
70-
summary: >-
71-
Which parameter of the preceding generator this input drives.
72-
`frequency` applies to `tone` only. Omitted when the input supplies the
73-
waveform directly.
74-
agreement: agreed
7572
- name: type
7673
type: enum
7774
values: [buffer, value]
@@ -83,24 +80,61 @@ elements:
8380

8481
- name: tone
8582
parent: audio
86-
since: "1.0"
87-
summary: A tone generator. Its parameters come from the input elements after it.
83+
since: "1.10"
84+
summary: >-
85+
A parametric tone generator, driven by the input elements inside it. Each
86+
tone tracks the phase of its wave so that changing the frequency does not
87+
click. Several are allowed.
88+
children: [input]
8889
attributes:
8990
- name: waveform
9091
type: enum
9192
values: [sine, square, sawtooth]
9293
default: sine
94+
since: "1.19"
9395
summary: Shape of the generated wave.
9496
agreement: divergent
9597
inconsistency: input-invalid-enum-handling
9698
rules: [enum-invalid-value, enum-case-insensitive]
9799

100+
- name: input
101+
parent: tone
102+
since: "1.10"
103+
summary: Drives one parameter of the tone generator.
104+
text: Name of a data container, or a literal number when type is value.
105+
attributes: &plugin_input
106+
- name: parameter
107+
type: enum
108+
values: [pan, amplitude, duration, frequency]
109+
summary: >-
110+
Which parameter this input drives. Only `tone` accepts `frequency`.
111+
agreement: agreed
112+
- name: type
113+
type: enum
114+
values: [buffer, value]
115+
default: buffer
116+
summary: Whether the text names a data container or is a literal value.
117+
agreement: divergent
118+
inconsistency: input-invalid-enum-handling
119+
rules: [enum-invalid-value, enum-case-insensitive]
120+
98121
- name: noise
99122
parent: audio
100-
since: "1.0"
101-
summary: A noise generator. Its parameters come from the input elements after it.
123+
since: "1.10"
124+
summary: >-
125+
A white-noise generator, driven by the input elements inside it. Only one
126+
is supported.
127+
children: [input]
102128
attributes: []
103129

130+
- name: input
131+
parent: noise
132+
since: "1.10"
133+
summary: >-
134+
Drives one parameter of the noise generator. `frequency` does not apply.
135+
text: Name of a data container, or a literal number when type is value.
136+
attributes: *plugin_input
137+
104138
# -------------------------------------------------------------- flashlight --
105139
- name: flashlight
106140
parent: output

‎spec/root.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,12 @@ elements:
327327
than by hand, and intended to be replaced by a proper state format.
328328
attributes: []
329329
children: [start, pause]
330+
note: >-
331+
iOS accepts any child whose name matches a TimeMappingEvent, which includes
332+
<clear> as well as <start> and <pause>; Android switches on the two the
333+
documentation names. No file should contain a clear event - the event list
334+
is emptied when data is cleared, so it never survives to be written - so
335+
this is latitude on the iOS side rather than a divergence in the format.
330336
331337
- name: start
332338
parent: events

‎tools/spec_vs_ios.py‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
#!/usr/bin/env python3
2+
"""Check spec/*.yml against the attributes iOS's handlers declare.
3+
4+
python3 tools/spec_vs_ios.py [path-to-phyphox-ios]
5+
6+
iOS states its accepted surface unusually plainly: every element handler carries
7+
an `enum Attribute` listing exactly what it reads, and registers its children by
8+
name. Walking that from the root element gives an independent inventory of the
9+
format to diff the spec against - one that does not depend on my having read the
10+
right lines.
11+
12+
It found a real structural error on its first run: <input> under <tone> and
13+
<noise> in the audio output, which the spec had modelled only under <audio>.
14+
15+
Three traps this has to avoid, all of which produced false findings before being
16+
handled:
17+
18+
* **Class bodies must be brace-matched.** A regex ending at the first `\\n}`
19+
runs past the end of a class and picks up the next one's attributes. That
20+
reported `experimentTime` on <events> (it belongs to <start>/<pause>) and
21+
`stride` on a bluetooth output (it belongs to a neighbouring sensor handler).
22+
* **Class names are not unique.** OutputElementHandler.swift declares its own
23+
private AudioElementHandler and BluetoothElementHandler, with the same names
24+
as the input block's. Keys are therefore file-qualified.
25+
* **childHandlers maps names to variables, not classes.** They have to be
26+
resolved through the `let x = SomeHandler()` bindings in the same class.
27+
28+
Anything iOS declares but never reads is listed in DECLARED_BUT_UNREAD rather
29+
than modelled: it is in the source, but it is not part of the format.
30+
"""
31+
32+
import json
33+
import os
34+
import re
35+
import sys
36+
37+
import yaml
38+
39+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
40+
SPEC = os.path.join(ROOT, "spec")
41+
DEFAULT_IOS = os.path.join(os.path.dirname(ROOT), "phyphox-ios")
42+
43+
# (parent, child) -> attributes iOS declares in its Attribute enum and never
44+
# reads. Checked by hand; each is dead code rather than part of the format.
45+
DECLARED_BUT_UNREAD = {
46+
("bluetooth", "output"): {"stride"},
47+
("dropdown", "map"): {"replacement"},
48+
("tone", "input"): {"clear"},
49+
("noise", "input"): {"clear"},
50+
("input", "bluetooth"): {"mtu"}, # honoured on Android, ignored here
51+
("phyphox", "events"): {"experimentTime", "systemTime"}, # copy of its child's enum
52+
}
53+
54+
# Elements iOS reaches through a computed childHandler rather than a literal
55+
# switch, so the walk cannot see them. Checked by hand instead.
56+
# events/start, events/pause - accepted via TimeMappingEvent(rawValue:)
57+
# analysis/<module> - any name, dispatched by ExperimentAnalysisFactory
58+
59+
60+
def class_bodies(src):
61+
"""Yield (class_name, body) with bodies delimited by brace matching."""
62+
for m in re.finditer(r'class (\w+)\s*:[^{]*\{', src):
63+
depth, i = 1, m.end()
64+
while i < len(src) and depth:
65+
if src[i] == "{":
66+
depth += 1
67+
elif src[i] == "}":
68+
depth -= 1
69+
i += 1
70+
yield m.group(1), src[m.end():i - 1]
71+
72+
73+
def scan(ios_root):
74+
handlers_dir = os.path.join(ios_root, "phyphox-iOS", "phyphox",
75+
"Experiments", "Serialization", "Handlers")
76+
handlers = {}
77+
for dirpath, _, files in os.walk(handlers_dir):
78+
for fn in files:
79+
if not fn.endswith(".swift"):
80+
continue
81+
src = open(os.path.join(dirpath, fn), encoding="utf-8").read()
82+
for cls, body in class_bodies(src):
83+
attrs = []
84+
for em in re.finditer(r'enum Attribute[^{]*\{(.*?)\n\s*\}', body, re.S):
85+
for c in re.finditer(r'case (\w+)(?:\s*=\s*"([^"]+)")?', em.group(1)):
86+
attrs.append(c.group(2) or c.group(1))
87+
binding = dict(re.findall(
88+
r'(?:let|var) (\w+)\s*(?::\s*\w+)?\s*=\s*(\w+ElementHandler)\(\)', body))
89+
kids = {}
90+
ch = re.search(r'childHandlers\s*=\s*\[(.*?)\]', body, re.S)
91+
if ch:
92+
for name, var in re.findall(r'"([\w\-]+)"\s*:\s*(\w+)', ch.group(1)):
93+
kids[name] = (fn, binding.get(var, var))
94+
for cm in re.finditer(r'case "([\w\-]+)":\s*\n?\s*handler = (\w+)', body):
95+
kids[cm.group(1)] = (fn, binding.get(cm.group(2), cm.group(2)))
96+
handlers[f"{fn}::{cls}"] = {"attrs": sorted(set(attrs)), "children": kids}
97+
return handlers
98+
99+
100+
def walk(handlers):
101+
def resolve(hint, cls):
102+
if f"{hint}::{cls}" in handlers:
103+
return f"{hint}::{cls}"
104+
return next((k for k in handlers if k.endswith(f"::{cls}")), None)
105+
106+
root = "PhyphoxElementHandler.swift::PhyphoxElementHandler"
107+
pairs, frontier, seen = {}, [("phyphox", root)], set()
108+
while frontier:
109+
pname, pkey = frontier.pop()
110+
if (pname, pkey) in seen:
111+
continue
112+
seen.add((pname, pkey))
113+
for cname, (fn, ccls) in (handlers.get(pkey, {}).get("children") or {}).items():
114+
ckey = resolve(fn, ccls)
115+
if ckey:
116+
pairs[(pname, cname)] = ckey
117+
frontier.append((cname, ckey))
118+
return pairs
119+
120+
121+
def spec_attributes():
122+
out = {}
123+
for fn in sorted(os.listdir(SPEC)):
124+
if not fn.endswith(".yml") or fn == "rules.yml":
125+
continue
126+
doc = yaml.safe_load(open(os.path.join(SPEC, fn), encoding="utf-8")) or {}
127+
common = {}
128+
for group, items in (doc.get("common") or {}).items():
129+
key = "input" if "input" in group else ("output" if "output" in group else None)
130+
if key:
131+
common[key] = {i["name"] for i in items or []}
132+
for el in doc.get("elements") or []:
133+
key = (el.get("parent"), el["name"])
134+
out[key] = out.get(key, set()) | {a["name"] for a in (el.get("attributes") or [])}
135+
outputs = el.get("outputs")
136+
if isinstance(outputs, dict) and outputs.get("attribute"):
137+
k2 = (el["name"], "output")
138+
out[k2] = out.get(k2, set()) | {outputs["attribute"]}
139+
for el in doc.get("elements") or []:
140+
for child in el.get("children") or []:
141+
if child in common:
142+
k2 = (el["name"], child)
143+
out[k2] = out.get(k2, set()) | common[child]
144+
return out
145+
146+
147+
def main():
148+
ios_root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_IOS
149+
if not os.path.isdir(ios_root):
150+
print(f"phyphox-ios not found at {ios_root} - pass its path as an argument.")
151+
return 0
152+
153+
handlers = scan(ios_root)
154+
pairs = walk(handlers)
155+
spec = spec_attributes()
156+
157+
problems = []
158+
for (pname, cname), key in sorted(pairs.items()):
159+
declared = set(handlers.get(key, {}).get("attrs") or [])
160+
declared -= DECLARED_BUT_UNREAD.get((pname, cname), set())
161+
modelled = spec.get((pname, cname))
162+
if modelled is None:
163+
if declared:
164+
problems.append(f"{pname}/{cname}: not modelled at all "
165+
f"(iOS declares {sorted(declared)})")
166+
else:
167+
gap = sorted(declared - modelled)
168+
if gap:
169+
problems.append(f"{pname}/{cname}: iOS declares {gap}, spec does not")
170+
171+
print(f"{len(pairs)} parent/child pairs reachable from <phyphox> in the iOS handlers")
172+
if problems:
173+
print()
174+
for p in problems:
175+
print(f" {p}")
176+
print(f"\n{len(problems)} gap(s).")
177+
return 1
178+
print("No attribute iOS declares is missing from the spec.")
179+
return 0
180+
181+
182+
if __name__ == "__main__":
183+
sys.exit(main())

0 commit comments

Comments
 (0)