diff --git a/conformance/README.md b/conformance/README.md index 5ad6c44..1603d05 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,18 +1,78 @@ # Protocol conformance -Conformance fixtures give every Murmur SDK the same observable behavior. They -use the ProtoJSON field names and enum values defined in `spec/`. - -An SDK is conformant for a fixture set when it can: - -1. parse every line without losing the protocol, session, sequence, timing, or - payload variant; -2. reject an unknown payload variant rather than silently treating it as a - command; -3. preserve 64-bit integer values even on runtimes whose JSON number type - cannot represent them safely; -4. serialize an equivalent ProtoJSON object; object key order is irrelevant; -5. keep transcript text and audio out of diagnostics produced during parsing. - -Fixtures contain synthetic text only. Real recordings and conversations do not -belong in this directory. +The JSON Lines fixtures give every Murmur SDK the same observable behavior. +They use the ProtoJSON field and enum names defined in `spec/`, contain only +synthetic data, and must never contain recordings, conversations, credentials, +or real identifiers. + +## Manifest and runner contract + +`manifest.json` is the only fixture index. `manifestVersion` identifies this +manifest shape; `protocol` identifies the current wire contract. Every member +of `fixtureSets` supplies a unique `name`, an explicit `message`, a path, line +count, and either `expect: accept` or `expect: reject`. + +Reject sets also declare a stable `reason`, a `rejection` phase (`parse` or +`order`), and optionally `rejectLine` (one-based, default 1). For parse +rejections every line at or after `rejectLine` must fail parsing. For order +rejections every line must parse, lines before `rejectLine` must strictly +increase, and each line from `rejectLine` onward must fail only that ordering +predicate. Reason values are `missing-payload`, `ambiguous-oneof`, +`missing-session-command`, `ambiguous-session-command`, `invalid-enum`, +`sequence-order`, `invalid-uint64`, `unsupported-protocol-major`, +`invalid-protocol-version`, and `invalid-audio-frame`. + +Accept sets may declare slash-separated `unknownFields`. A runner parses each +line, computes its ordering key (`sequence` for RuntimeEvent and AudioFrame, +`requestSequence` for SessionControl, none for VoiceSource), and checks the +expected result. Accepted values are serialized and compared structurally with +the input after every declared unknown path has been removed from both values. +Parse success is asserted separately. Failure messages use +` · · line N · ` and round-trip failures end in +`· round-trip`. + +Unknown additive fields beside a known oneof arm are ignored for a supported +major. A message whose only arm is unknown is rejected because it has no known +arm. Accepted fixtures are canonical: default-valued fields are omitted. + +## Compatibility profile + +| Concern | Wire (protobuf / ProtoJSON) | Murmur profile (fixture-enforced) | SDK policy | +| --- | --- | --- | --- | +| uint64 | ProtoJSON parses integer numbers or strings, including exponent forms | Serialize decimal strings; string input is ASCII digits only and at most 2^64-1 | SDKs may additionally accept exact, non-negative JSON integers; in-memory type is language-specific | +| uint32 | JSON integer number | Number must be an integer, not a boolean, from 0 through 2^32-1 | Shared helper and language-specific error type | +| protocol | Version fields are uint32 | Major must equal 1; every non-negative uint32 minor is accepted | Envelope parsers always enforce support | +| oneof | One selected arm | Exactly one known arm is required | Error representation is language-specific | +| unknown fields | Rejected by ProtoJSON parsers by default | Ignored within a supported major | SDKs need not preserve them | +| enums | ProtoJSON accepts names and integers | Validated fields accept known string names only; integers are rejected | Unvalidated opaque bodies remain unchanged | +| bytes | Base64 string | Standard or URL-safe base64 grammar below | SDKs keep the encoded string and need not decode bytes | +| ordering | Application concern | Keys in an accepted set must strictly increase | No sequence-tracking API is required | +| defaults | Usually emitted implicitly by binary encoding | Omit defaults in canonical JSON; VoiceSource omits empty capabilities and metadata | SDKs may always emit required envelope fields | + +Validated RuntimeEvent fields are transcript `kind` and `text`, audio-level +`amplitude` in [0, 1], and session-state `previous` and `current`. SessionControl +validates optional input-gate booleans, optional stop reason, and optional start +mode, source, and requested format. AudioFrame validates its format and payload. +VoiceSource requires non-empty identifiers and display names, a known transport, +known capabilities, and string-to-string metadata. Intent, confirmation, +action-result, error, and all other body data remain opaque and are echoed. + +Base64 validation counts trailing `=` characters as padding (at most two). +After stripping padding, a length remainder of one modulo four is invalid. If +padding is present it must equal `(4 - remainder) % 4`, and total length must be +divisible by four. The unpadded content must use either the standard alphabet +or the URL-safe alphabet, never a mixture. The empty string is valid. + +## Synthetic audio + +Both audio fixtures are 16,000 Hz, mono, `AUDIO_ENCODING_PCM_S16LE`, and 10 ms: +160 samples and 320 bytes. The first is a 1 kHz sine wave with sample `n` equal +to `round(8000 * sin(2*pi*1000*n/16000))`, encoded little-endian signed int16. +The second is 320 zero bytes. Recreate the first payload with: + +```sh +python3 -c "import base64,math,struct; print(base64.b64encode(b''.join(struct.pack(' payload; @@ -63,8 +63,8 @@ final class RuntimeEvent { ); } - final sequence = _parseUint64(json['sequence'], 'sequence'); - final monotonicTimeUs = _parseUint64( + final sequence = parseUint64(json['sequence'], 'sequence'); + final monotonicTimeUs = parseUint64( json['monotonicTimeUs'], 'monotonicTimeUs', ); @@ -106,21 +106,15 @@ final class RuntimeEvent { String toJsonString() => jsonEncode(toJson()); } -int _parseUint64(Object? value, String field) { - final parsed = switch (value) { - final int number => number, - final String text => int.tryParse(text), - _ => null, - }; - if (parsed == null || parsed < 0) { - throw FormatException('$field must be a non-negative uint64 string'); - } - return parsed; -} - void _validatePayload(RuntimePayloadKind kind, Map payload) { if (kind == RuntimePayloadKind.transcript) { - if (payload['kind'] is! String || payload['text'] is! String) { + const kinds = { + 'TRANSCRIPT_KIND_UNSPECIFIED', + 'TRANSCRIPT_KIND_PARTIAL', + 'TRANSCRIPT_KIND_FINAL', + 'TRANSCRIPT_KIND_REJECTED', + }; + if (!kinds.contains(payload['kind']) || payload['text'] is! String) { throw const FormatException('transcript requires kind and text'); } } @@ -132,4 +126,22 @@ void _validatePayload(RuntimePayloadKind kind, Map payload) { ); } } + if (kind == RuntimePayloadKind.sessionStateChanged) { + const states = { + 'SESSION_STATE_UNSPECIFIED', + 'SESSION_STATE_IDLE', + 'SESSION_STATE_STARTING', + 'SESSION_STATE_LISTENING', + 'SESSION_STATE_WARM_MUTED', + 'SESSION_STATE_FINALIZING', + 'SESSION_STATE_STOPPED', + 'SESSION_STATE_ERROR', + }; + if (!states.contains(payload['previous']) || + !states.contains(payload['current'])) { + throw const FormatException( + 'sessionStateChanged requires known previous and current states', + ); + } + } } diff --git a/sdks/dart/murmur_protocol/lib/src/protocol.dart b/sdks/dart/murmur_protocol/lib/src/protocol.dart index 694eed7..e5f0ad5 100644 --- a/sdks/dart/murmur_protocol/lib/src/protocol.dart +++ b/sdks/dart/murmur_protocol/lib/src/protocol.dart @@ -8,19 +8,19 @@ final class ProtocolVersion { final int minor; factory ProtocolVersion.fromJson(Map json) { - final major = json['major']; - final minor = json['minor']; - if (major is! num || minor is! num) { - throw const FormatException( - 'protocol.major and protocol.minor are required numbers', - ); + final version = ProtocolVersion( + major: parseUint32(json['major'], 'protocol.major'), + minor: parseUint32(json['minor'], 'protocol.minor'), + ); + if (!version.isSupported) { + throw FormatException('unsupported protocol major ${version.major}'); } - return ProtocolVersion(major: major.toInt(), minor: minor.toInt()); + return version; } Map toJson() => {'major': major, 'minor': minor}; - bool get isSupported => major == current.major && minor <= current.minor; + bool get isSupported => major == current.major; @override bool operator ==(Object other) => @@ -29,3 +29,63 @@ final class ProtocolVersion { @override int get hashCode => Object.hash(major, minor); } + +const _uint32Max = 0xffffffff; +final _asciiUint = RegExp(r'^[0-9]+$'); +final _standardBase64 = RegExp(r'^[A-Za-z0-9+/]*$'); +final _urlSafeBase64 = RegExp(r'^[A-Za-z0-9_-]*$'); +final _uint64Max = BigInt.parse('18446744073709551615'); + +int parseUint32(Object? value, String field) { + if (value is! int || value < 0 || value > _uint32Max) { + throw FormatException('$field must be a uint32'); + } + return value; +} + +BigInt parseUint64(Object? value, String field) { + final BigInt? parsed; + if (value is int && value >= 0) { + parsed = BigInt.from(value); + } else if (value is String && _asciiUint.hasMatch(value)) { + parsed = BigInt.tryParse(value); + } else { + parsed = null; + } + if (parsed == null || parsed > _uint64Max) { + throw FormatException('$field must be an ASCII uint64 within range'); + } + return parsed; +} + +Map requireObject(Object? value, String field) { + if (value is! Map) { + throw FormatException('$field must be an object'); + } + return value; +} + +String requireNonEmptyString(Object? value, String field) { + if (value is! String || value.trim().isEmpty) { + throw FormatException('$field must be a non-empty string'); + } + return value; +} + +bool isValidBase64(String value) { + var pad = 0; + for ( + var index = value.length - 1; + index >= 0 && value[index] == '='; + index-- + ) { + pad++; + } + if (pad > 2) return false; + final raw = pad == 0 ? value : value.substring(0, value.length - pad); + if (raw.contains('=') || raw.length % 4 == 1) return false; + if (pad > 0 && (pad != (4 - raw.length % 4) % 4 || value.length % 4 != 0)) { + return false; + } + return _standardBase64.hasMatch(raw) || _urlSafeBase64.hasMatch(raw); +} diff --git a/sdks/dart/murmur_protocol/lib/src/session.dart b/sdks/dart/murmur_protocol/lib/src/session.dart new file mode 100644 index 0000000..204a5af --- /dev/null +++ b/sdks/dart/murmur_protocol/lib/src/session.dart @@ -0,0 +1,176 @@ +import 'dart:convert'; + +import 'protocol.dart'; +import 'source.dart'; + +enum SessionCommandKind { + start('start'), + stop('stop'), + inputGate('inputGate'), + finalize('finalize'); + + const SessionCommandKind(this.protoJsonField); + + final String protoJsonField; +} + +final class SessionControl { + SessionControl({ + required this.protocol, + required this.sessionId, + required this.requestSequence, + required this.kind, + required Map body, + }) : body = Map.unmodifiable(body); + + final ProtocolVersion protocol; + final String sessionId; + final BigInt requestSequence; + final SessionCommandKind kind; + final Map body; + + factory SessionControl.fromJsonString(String source) { + return SessionControl.fromJson( + requireObject(jsonDecode(source), 'session control'), + ); + } + + factory SessionControl.fromJson(Map json) { + final present = SessionCommandKind.values + .where((candidate) => json.containsKey(candidate.protoJsonField)) + .toList(growable: false); + if (present.length != 1) { + throw const FormatException( + 'session control must contain exactly one known command', + ); + } + final kind = present.single; + final body = requireObject(json[kind.protoJsonField], kind.protoJsonField); + _validateSessionBody(kind, body); + return SessionControl( + protocol: ProtocolVersion.fromJson( + requireObject(json['protocol'], 'protocol'), + ), + sessionId: requireNonEmptyString(json['sessionId'], 'sessionId'), + requestSequence: parseUint64(json['requestSequence'], 'requestSequence'), + kind: kind, + body: body, + ); + } + + Map toJson() => { + 'protocol': protocol.toJson(), + 'sessionId': sessionId, + 'requestSequence': requestSequence.toString(), + kind.protoJsonField: body, + }; +} + +final class AudioFrame { + AudioFrame({ + required this.protocol, + required this.sessionId, + required this.sequence, + required this.monotonicTimeUs, + required Map format, + required this.payloadBase64, + }) : format = Map.unmodifiable(format); + + final ProtocolVersion protocol; + final String sessionId; + final BigInt sequence; + final BigInt monotonicTimeUs; + final Map format; + final String payloadBase64; + + factory AudioFrame.fromJsonString(String source) { + return AudioFrame.fromJson( + requireObject(jsonDecode(source), 'audio frame'), + ); + } + + factory AudioFrame.fromJson(Map json) { + final format = requireObject(json['format'], 'format'); + validateAudioFormat(format); + final payload = json['payload']; + if (payload is! String || !isValidBase64(payload)) { + throw const FormatException('payload must use valid base64 grammar'); + } + return AudioFrame( + protocol: ProtocolVersion.fromJson( + requireObject(json['protocol'], 'protocol'), + ), + sessionId: requireNonEmptyString(json['sessionId'], 'sessionId'), + sequence: parseUint64(json['sequence'], 'sequence'), + monotonicTimeUs: parseUint64(json['monotonicTimeUs'], 'monotonicTimeUs'), + format: format, + payloadBase64: payload, + ); + } + + Map toJson() => { + 'protocol': protocol.toJson(), + 'sessionId': sessionId, + 'sequence': sequence.toString(), + 'monotonicTimeUs': monotonicTimeUs.toString(), + 'format': format, + 'payload': payloadBase64, + }; +} + +void validateAudioFormat(Map format) { + if (parseUint32(format['sampleRateHz'], 'sampleRateHz') < 1) { + throw const FormatException('sampleRateHz must be at least 1'); + } + if (parseUint32(format['channels'], 'channels') < 1) { + throw const FormatException('channels must be at least 1'); + } + const encodings = { + 'AUDIO_ENCODING_PCM_S16LE', + 'AUDIO_ENCODING_PCM_F32LE', + 'AUDIO_ENCODING_OPUS', + }; + if (!encodings.contains(format['encoding'])) { + throw const FormatException( + 'encoding must be a known non-unspecified name', + ); + } + if (format.containsKey('frameDurationMs')) { + parseUint32(format['frameDurationMs'], 'frameDurationMs'); + } +} + +void _validateSessionBody(SessionCommandKind kind, Map body) { + if (kind == SessionCommandKind.inputGate) { + for (final field in ['open', 'flushAcceptedAudio']) { + if (body.containsKey(field) && body[field] is! bool) { + throw FormatException('$field must be a boolean'); + } + } + } else if (kind == SessionCommandKind.stop && + body.containsKey('reason') && + body['reason'] is! String) { + throw const FormatException('stop.reason must be a string'); + } else if (kind == SessionCommandKind.start) { + if (body.containsKey('mode')) { + const modes = { + 'CAPTURE_MODE_UNSPECIFIED', + 'CAPTURE_MODE_TAP_TO_SPEAK', + 'CAPTURE_MODE_HOLD_TO_TALK', + 'CAPTURE_MODE_HANDS_FREE', + 'CAPTURE_MODE_WAKE_PHRASE', + }; + if (!modes.contains(body['mode'])) { + throw const FormatException('start.mode must be a known enum name'); + } + } + if (body.containsKey('source')) { + VoiceSource.fromJson(requireObject(body['source'], 'start.source')); + } + if (body.containsKey('requestedFormat')) { + validateAudioFormat( + requireObject(body['requestedFormat'], 'start.requestedFormat'), + ); + } + } +} diff --git a/sdks/dart/murmur_protocol/lib/src/source.dart b/sdks/dart/murmur_protocol/lib/src/source.dart index 26e9126..1068e33 100644 --- a/sdks/dart/murmur_protocol/lib/src/source.dart +++ b/sdks/dart/murmur_protocol/lib/src/source.dart @@ -1,20 +1,30 @@ +import 'protocol.dart'; + enum VoiceSourceTransport { - bluetoothLowEnergy, - localAudio, - network, - file, - synthetic, + bluetoothLowEnergy('SOURCE_TRANSPORT_BLUETOOTH_LE'), + localAudio('SOURCE_TRANSPORT_LOCAL_AUDIO'), + network('SOURCE_TRANSPORT_NETWORK'), + file('SOURCE_TRANSPORT_FILE'), + synthetic('SOURCE_TRANSPORT_SYNTHETIC'); + + const VoiceSourceTransport(this.protoJsonName); + + final String protoJsonName; } enum VoiceSourceCapability { - liveAudio, - storedAudio, - battery, - hardwareControl, - outputAudio, - backgroundCapture, - inputMute, - speakerVerification, + liveAudio('SOURCE_CAPABILITY_LIVE_AUDIO'), + storedAudio('SOURCE_CAPABILITY_STORED_AUDIO'), + battery('SOURCE_CAPABILITY_BATTERY'), + hardwareControl('SOURCE_CAPABILITY_HARDWARE_CONTROL'), + outputAudio('SOURCE_CAPABILITY_OUTPUT_AUDIO'), + backgroundCapture('SOURCE_CAPABILITY_BACKGROUND_CAPTURE'), + inputMute('SOURCE_CAPABILITY_INPUT_MUTE'), + speakerVerification('SOURCE_CAPABILITY_SPEAKER_VERIFICATION'); + + const VoiceSourceCapability(this.protoJsonName); + + final String protoJsonName; } /// A source-neutral description of something that can provide voice input. @@ -45,6 +55,59 @@ final class VoiceSource { final Set capabilities; final Map metadata; + factory VoiceSource.fromJson(Map json) { + final rawCapabilities = json['capabilities'] ?? const []; + final rawMetadata = json['metadata'] ?? const {}; + if (rawCapabilities is! List) { + throw const FormatException('capabilities must be an array'); + } + final metadataObject = requireObject(rawMetadata, 'metadata'); + final metadata = {}; + for (final entry in metadataObject.entries) { + final value = entry.value; + if (value is! String) { + throw const FormatException('metadata values must be strings'); + } + metadata[entry.key] = value; + } + return VoiceSource( + id: requireNonEmptyString(json['sourceId'], 'sourceId'), + displayName: requireNonEmptyString(json['displayName'], 'displayName'), + transport: _transportFromWire(json['transport']), + capabilities: rawCapabilities.map(_capabilityFromWire).toSet(), + metadata: metadata, + ); + } + + Map toJson() { + final result = { + 'sourceId': id, + 'displayName': displayName, + 'transport': transport.protoJsonName, + }; + if (capabilities.isNotEmpty) { + result['capabilities'] = capabilities + .map((capability) => capability.protoJsonName) + .toList(); + } + if (metadata.isNotEmpty) result['metadata'] = metadata; + return result; + } + bool supports(VoiceSourceCapability capability) => capabilities.contains(capability); } + +VoiceSourceTransport _transportFromWire(Object? value) { + for (final transport in VoiceSourceTransport.values) { + if (transport.protoJsonName == value) return transport; + } + throw const FormatException('transport must be a known enum name'); +} + +VoiceSourceCapability _capabilityFromWire(Object? value) { + for (final capability in VoiceSourceCapability.values) { + if (capability.protoJsonName == value) return capability; + } + throw const FormatException('capability must be a known enum name'); +} diff --git a/sdks/dart/murmur_protocol/test/conformance_test.dart b/sdks/dart/murmur_protocol/test/conformance_test.dart index 71c0ceb..ac93415 100644 --- a/sdks/dart/murmur_protocol/test/conformance_test.dart +++ b/sdks/dart/murmur_protocol/test/conformance_test.dart @@ -5,53 +5,77 @@ import 'package:murmur_protocol/murmur_protocol.dart'; import 'package:test/test.dart'; void main() { - test('parses and preserves every shared runtime event fixture', () { - final fixtures = File( - '../../../conformance/fixtures/runtime-events.jsonl', - ).readAsLinesSync().where((line) => line.trim().isNotEmpty).toList(); - - final events = fixtures.map(RuntimeEvent.fromJsonString).toList(); - - expect(events, hasLength(7)); - expect( - events.map((event) => event.sequence), - orderedEquals(List.generate(7, (i) => i + 1)), + test('passes every manifest conformance set', () { + final conformance = Directory('../../../conformance'); + final manifest = requireObject( + jsonDecode(File('${conformance.path}/manifest.json').readAsStringSync()), + 'manifest', ); - expect(events[3].kind, RuntimePayloadKind.transcript); - expect(events[3].payload['text'], 'synthetic hello'); - expect(events[5].kind, RuntimePayloadKind.intentProposal); - - for (var index = 0; index < events.length; index++) { + final fixtureSets = manifest['fixtureSets']! as List; + for (final fixtureValue in fixtureSets) { + final fixtureSet = requireObject(fixtureValue, 'fixture set'); + final name = fixtureSet['name']! as String; + final message = fixtureSet['message']! as String; + final lines = File( + '${conformance.path}/${fixtureSet['path']}', + ).readAsLinesSync().where((line) => line.trim().isNotEmpty).toList(); expect( - jsonDecode(events[index].toJsonString()), - jsonDecode(fixtures[index]), + lines, + hasLength(fixtureSet['lines']! as int), + reason: 'dart · $name · count', ); + BigInt? previous; + final rejectLine = fixtureSet['rejectLine'] as int? ?? 1; + for (var index = 0; index < lines.length; index++) { + final lineNumber = index + 1; + final label = 'dart · $name · line $lineNumber'; + final input = requireObject(jsonDecode(lines[index]), message); + Object parsed; + try { + parsed = _parse(message, input); + } on Object catch (error) { + final shouldReject = + fixtureSet['expect'] == 'reject' && + fixtureSet['rejection'] == 'parse' && + lineNumber >= rejectLine; + expect(shouldReject, isTrue, reason: '$label · $error'); + continue; + } + if (fixtureSet['expect'] == 'reject' && + fixtureSet['rejection'] == 'parse' && + lineNumber >= rejectLine) { + fail('$label · expected ${fixtureSet['reason']}'); + } + final current = _orderingKey(parsed); + if (current != null) { + final ordered = previous == null || current > previous; + final rejectsOrder = + fixtureSet['expect'] == 'reject' && + fixtureSet['rejection'] == 'order' && + lineNumber >= rejectLine; + expect( + ordered, + !rejectsOrder, + reason: '$label · ${fixtureSet['reason'] ?? 'ordering'}', + ); + if (ordered) previous = current; + } + final expected = requireObject(jsonDecode(lines[index]), message); + final actual = requireObject( + jsonDecode(jsonEncode(_serialize(parsed))), + message, + ); + final unknownFields = + fixtureSet['unknownFields'] as List? ?? const []; + for (final path in unknownFields.cast()) { + _removePath(expected, path); + _removePath(actual, path); + } + expect(actual, equals(expected), reason: '$label · round-trip'); + } } }); - test('rejects unknown or ambiguous payloads', () { - const base = { - 'protocol': {'major': 1, 'minor': 0}, - 'sessionId': 'test', - 'sequence': '1', - 'monotonicTimeUs': '1', - }; - - expect( - () => - RuntimeEvent.fromJson({...base, 'unknownEvent': {}}), - throwsFormatException, - ); - expect( - () => RuntimeEvent.fromJson({ - ...base, - 'captureReadiness': {'live': true}, - 'audioLevel': {'amplitude': 0.5}, - }), - throwsFormatException, - ); - }); - test('models a source without Flutter or a connector SDK', () { final source = VoiceSource( id: 'synthetic-1', @@ -59,8 +83,41 @@ void main() { transport: VoiceSourceTransport.synthetic, capabilities: {VoiceSourceCapability.liveAudio}, ); - expect(source.supports(VoiceSourceCapability.liveAudio), isTrue); expect(source.supports(VoiceSourceCapability.battery), isFalse); }); } + +Object _parse(String message, Map input) => switch (message) { + 'RuntimeEvent' => RuntimeEvent.fromJson(input), + 'SessionControl' => SessionControl.fromJson(input), + 'AudioFrame' => AudioFrame.fromJson(input), + 'VoiceSource' => VoiceSource.fromJson(input), + _ => throw FormatException('unknown message $message'), +}; + +Map _serialize(Object value) => switch (value) { + final RuntimeEvent event => event.toJson(), + final SessionControl control => control.toJson(), + final AudioFrame frame => frame.toJson(), + final VoiceSource source => source.toJson(), + _ => throw FormatException('unknown parsed type ${value.runtimeType}'), +}; + +BigInt? _orderingKey(Object value) => switch (value) { + final RuntimeEvent event => event.sequence, + final SessionControl control => control.requestSequence, + final AudioFrame frame => frame.sequence, + _ => null, +}; + +void _removePath(Map value, String path) { + final parts = path.split('/'); + Map? current = value; + for (final part in parts.take(parts.length - 1)) { + final next = current?[part]; + if (next is! Map) return; + current = next; + } + current?.remove(parts.last); +} diff --git a/sdks/python/src/murmur_protocol/__init__.py b/sdks/python/src/murmur_protocol/__init__.py index 80a0e6d..d82380f 100644 --- a/sdks/python/src/murmur_protocol/__init__.py +++ b/sdks/python/src/murmur_protocol/__init__.py @@ -1,3 +1,21 @@ -from .models import PayloadKind, ProtocolVersion, RuntimeEvent, VoiceSource +from .models import ( + AudioFrame, + PayloadKind, + ProtocolVersion, + RuntimeEvent, + SessionCommandKind, + SessionControl, + VoiceSource, + is_supported, +) -__all__ = ["PayloadKind", "ProtocolVersion", "RuntimeEvent", "VoiceSource"] +__all__ = [ + "AudioFrame", + "PayloadKind", + "ProtocolVersion", + "RuntimeEvent", + "SessionCommandKind", + "SessionControl", + "VoiceSource", + "is_supported", +] diff --git a/sdks/python/src/murmur_protocol/models.py b/sdks/python/src/murmur_protocol/models.py index fbe22f3..f51cde4 100644 --- a/sdks/python/src/murmur_protocol/models.py +++ b/sdks/python/src/murmur_protocol/models.py @@ -1,11 +1,77 @@ from __future__ import annotations import json +import re from dataclasses import dataclass, field from enum import StrEnum from types import MappingProxyType from typing import Any, Mapping +UINT32_MAX = 2**32 - 1 +UINT64_MAX = 2**64 - 1 +CURRENT_PROTOCOL_MAJOR = 1 +_ASCII_UINT = re.compile(r"^[0-9]+$") +_STANDARD_BASE64 = re.compile(r"^[A-Za-z0-9+/]*$") +_URLSAFE_BASE64 = re.compile(r"^[A-Za-z0-9_-]*$") + +TRANSCRIPT_KINDS = frozenset( + { + "TRANSCRIPT_KIND_UNSPECIFIED", + "TRANSCRIPT_KIND_PARTIAL", + "TRANSCRIPT_KIND_FINAL", + "TRANSCRIPT_KIND_REJECTED", + } +) +SESSION_STATES = frozenset( + { + "SESSION_STATE_UNSPECIFIED", + "SESSION_STATE_IDLE", + "SESSION_STATE_STARTING", + "SESSION_STATE_LISTENING", + "SESSION_STATE_WARM_MUTED", + "SESSION_STATE_FINALIZING", + "SESSION_STATE_STOPPED", + "SESSION_STATE_ERROR", + } +) +CAPTURE_MODES = frozenset( + { + "CAPTURE_MODE_UNSPECIFIED", + "CAPTURE_MODE_TAP_TO_SPEAK", + "CAPTURE_MODE_HOLD_TO_TALK", + "CAPTURE_MODE_HANDS_FREE", + "CAPTURE_MODE_WAKE_PHRASE", + } +) +AUDIO_ENCODINGS = frozenset( + {"AUDIO_ENCODING_PCM_S16LE", "AUDIO_ENCODING_PCM_F32LE", "AUDIO_ENCODING_OPUS"} +) +SOURCE_TRANSPORTS = frozenset( + { + "SOURCE_TRANSPORT_BLUETOOTH_LE", + "SOURCE_TRANSPORT_LOCAL_AUDIO", + "SOURCE_TRANSPORT_NETWORK", + "SOURCE_TRANSPORT_FILE", + "SOURCE_TRANSPORT_SYNTHETIC", + } +) +SOURCE_CAPABILITIES = frozenset( + { + "SOURCE_CAPABILITY_LIVE_AUDIO", + "SOURCE_CAPABILITY_STORED_AUDIO", + "SOURCE_CAPABILITY_BATTERY", + "SOURCE_CAPABILITY_HARDWARE_CONTROL", + "SOURCE_CAPABILITY_OUTPUT_AUDIO", + "SOURCE_CAPABILITY_BACKGROUND_CAPTURE", + "SOURCE_CAPABILITY_INPUT_MUTE", + "SOURCE_CAPABILITY_SPEAKER_VERIFICATION", + } +) + + +def is_supported(protocol: ProtocolVersion) -> bool: + return protocol.major == CURRENT_PROTOCOL_MAJOR + @dataclass(frozen=True, slots=True) class ProtocolVersion: @@ -14,11 +80,13 @@ class ProtocolVersion: @classmethod def from_dict(cls, value: Mapping[str, Any]) -> ProtocolVersion: - major = value.get("major") - minor = value.get("minor") - if not isinstance(major, int) or major < 0 or not isinstance(minor, int) or minor < 0: - raise ValueError("protocol.major and protocol.minor must be non-negative integers") - return cls(major=major, minor=minor) + protocol = cls( + major=_parse_uint32(value.get("major"), "protocol.major"), + minor=_parse_uint32(value.get("minor"), "protocol.minor"), + ) + if not is_supported(protocol): + raise ValueError(f"unsupported protocol major {protocol.major}") + return protocol def to_dict(self) -> dict[str, int]: return {"major": self.major, "minor": self.minor} @@ -35,6 +103,13 @@ class PayloadKind(StrEnum): ACTION_RESULT = "actionResult" +class SessionCommandKind(StrEnum): + START = "start" + STOP = "stop" + INPUT_GATE = "inputGate" + FINALIZE = "finalize" + + @dataclass(frozen=True, slots=True) class RuntimeEvent: protocol: ProtocolVersion @@ -46,37 +121,21 @@ class RuntimeEvent: @classmethod def from_json(cls, source: str) -> RuntimeEvent: - value = json.loads(source) - if not isinstance(value, dict): - raise ValueError("runtime event must be an object") - return cls.from_dict(value) + return cls.from_dict(_json_object(source, "runtime event")) @classmethod def from_dict(cls, value: Mapping[str, Any]) -> RuntimeEvent: - protocol_value = value.get("protocol") - if not isinstance(protocol_value, dict): - raise ValueError("runtime event requires protocol") - session_id = value.get("sessionId") - if not isinstance(session_id, str) or not session_id.strip(): - raise ValueError("sessionId must be a non-empty string") + protocol = _parse_protocol(value) + session_id = _non_empty_string(value.get("sessionId"), "sessionId") sequence = _parse_uint64(value.get("sequence"), "sequence") monotonic = _parse_uint64(value.get("monotonicTimeUs"), "monotonicTimeUs") present = [kind for kind in PayloadKind if kind.value in value] if len(present) != 1: raise ValueError("runtime event must contain exactly one known payload") kind = present[0] - payload = value[kind.value] - if not isinstance(payload, dict): - raise ValueError(f"{kind.value} must be an object") - _validate_payload(kind, payload) - return cls( - protocol=ProtocolVersion.from_dict(protocol_value), - session_id=session_id, - sequence=sequence, - monotonic_time_us=monotonic, - kind=kind, - payload=MappingProxyType(dict(payload)), - ) + payload = _object(value[kind.value], kind.value) + _validate_runtime_payload(kind, payload) + return cls(protocol, session_id, sequence, monotonic, kind, MappingProxyType(dict(payload))) def to_dict(self) -> dict[str, Any]: return { @@ -97,24 +156,215 @@ class VoiceSource: metadata: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({})) def __post_init__(self) -> None: - if not self.source_id.strip() or not self.display_name.strip(): - raise ValueError("source_id and display_name must not be empty") + _non_empty_string(self.source_id, "sourceId") + _non_empty_string(self.display_name, "displayName") + _known_name(self.transport, SOURCE_TRANSPORTS, "transport") + for capability in self.capabilities: + _known_name(capability, SOURCE_CAPABILITIES, "capability") + if any(not isinstance(key, str) or not isinstance(value, str) for key, value in self.metadata.items()): + raise ValueError("metadata must map strings to strings") + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> VoiceSource: + capabilities = value.get("capabilities", []) + metadata = value.get("metadata", {}) + if not isinstance(capabilities, list): + raise ValueError("capabilities must be an array") + if not isinstance(metadata, dict): + raise ValueError("metadata must be an object") + return cls( + source_id=_non_empty_string(value.get("sourceId"), "sourceId"), + display_name=_non_empty_string(value.get("displayName"), "displayName"), + transport=_known_name(value.get("transport"), SOURCE_TRANSPORTS, "transport"), + capabilities=tuple( + _known_name(capability, SOURCE_CAPABILITIES, "capability") + for capability in capabilities + ), + metadata=MappingProxyType(dict(metadata)), + ) + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "sourceId": self.source_id, + "displayName": self.display_name, + "transport": self.transport, + } + if self.capabilities: + result["capabilities"] = list(self.capabilities) + if self.metadata: + result["metadata"] = dict(self.metadata) + return result + + +@dataclass(frozen=True, slots=True) +class SessionControl: + protocol: ProtocolVersion + session_id: str + request_sequence: int + kind: SessionCommandKind + body: Mapping[str, Any] + + @classmethod + def from_json(cls, source: str) -> SessionControl: + return cls.from_dict(_json_object(source, "session control")) + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> SessionControl: + present = [kind for kind in SessionCommandKind if kind.value in value] + if len(present) != 1: + raise ValueError("session control must contain exactly one known command") + kind = present[0] + body = _object(value[kind.value], kind.value) + _validate_session_body(kind, body) + return cls( + _parse_protocol(value), + _non_empty_string(value.get("sessionId"), "sessionId"), + _parse_uint64(value.get("requestSequence"), "requestSequence"), + kind, + MappingProxyType(dict(body)), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "protocol": self.protocol.to_dict(), + "sessionId": self.session_id, + "requestSequence": str(self.request_sequence), + self.kind.value: dict(self.body), + } + + +@dataclass(frozen=True, slots=True) +class AudioFrame: + protocol: ProtocolVersion + session_id: str + sequence: int + monotonic_time_us: int + format: Mapping[str, Any] + payload_base64: str + + @classmethod + def from_json(cls, source: str) -> AudioFrame: + return cls.from_dict(_json_object(source, "audio frame")) + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> AudioFrame: + audio_format = _object(value.get("format"), "format") + _validate_audio_format(audio_format) + payload = value.get("payload") + if not isinstance(payload, str) or not _is_base64(payload): + raise ValueError("payload must use valid base64 grammar") + return cls( + _parse_protocol(value), + _non_empty_string(value.get("sessionId"), "sessionId"), + _parse_uint64(value.get("sequence"), "sequence"), + _parse_uint64(value.get("monotonicTimeUs"), "monotonicTimeUs"), + MappingProxyType(dict(audio_format)), + payload, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "protocol": self.protocol.to_dict(), + "sessionId": self.session_id, + "sequence": str(self.sequence), + "monotonicTimeUs": str(self.monotonic_time_us), + "format": dict(self.format), + "payload": self.payload_base64, + } + + +def _json_object(source: str, name: str) -> dict[str, Any]: + value = json.loads(source) + if not isinstance(value, dict): + raise ValueError(f"{name} must be an object") + return value + + +def _object(value: Any, field_name: str) -> Mapping[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"{field_name} must be an object") + return value + + +def _parse_protocol(value: Mapping[str, Any]) -> ProtocolVersion: + return ProtocolVersion.from_dict(_object(value.get("protocol"), "protocol")) + + +def _parse_uint32(value: Any, field_name: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= UINT32_MAX: + raise ValueError(f"{field_name} must be a uint32") + return value def _parse_uint64(value: Any, field_name: str) -> int: - if not isinstance(value, str) or not value.isdecimal(): - raise ValueError(f"{field_name} must be a uint64 string") + if not isinstance(value, str) or _ASCII_UINT.fullmatch(value) is None: + raise ValueError(f"{field_name} must be an ASCII uint64 string") parsed = int(value) - if parsed < 0 or parsed > 2**64 - 1: + if parsed > UINT64_MAX: raise ValueError(f"{field_name} is outside uint64 range") return parsed -def _validate_payload(kind: PayloadKind, payload: Mapping[str, Any]) -> None: +def _non_empty_string(value: Any, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + return value + + +def _known_name(value: Any, names: frozenset[str], field_name: str) -> str: + if not isinstance(value, str) or value not in names: + raise ValueError(f"{field_name} must be a known enum name") + return value + + +def _validate_runtime_payload(kind: PayloadKind, payload: Mapping[str, Any]) -> None: if kind is PayloadKind.TRANSCRIPT: - if not isinstance(payload.get("kind"), str) or not isinstance(payload.get("text"), str): - raise ValueError("transcript requires kind and text") - if kind is PayloadKind.AUDIO_LEVEL: + _known_name(payload.get("kind"), TRANSCRIPT_KINDS, "transcript.kind") + if not isinstance(payload.get("text"), str): + raise ValueError("transcript.text must be a string") + elif kind is PayloadKind.AUDIO_LEVEL: amplitude = payload.get("amplitude") - if not isinstance(amplitude, (int, float)) or isinstance(amplitude, bool) or not 0 <= amplitude <= 1: + if isinstance(amplitude, bool) or not isinstance(amplitude, (int, float)) or not 0 <= amplitude <= 1: raise ValueError("audioLevel.amplitude must be between 0 and 1") + elif kind is PayloadKind.SESSION_STATE_CHANGED: + _known_name(payload.get("previous"), SESSION_STATES, "sessionStateChanged.previous") + _known_name(payload.get("current"), SESSION_STATES, "sessionStateChanged.current") + + +def _validate_audio_format(value: Mapping[str, Any]) -> None: + if _parse_uint32(value.get("sampleRateHz"), "sampleRateHz") < 1: + raise ValueError("sampleRateHz must be at least 1") + if _parse_uint32(value.get("channels"), "channels") < 1: + raise ValueError("channels must be at least 1") + _known_name(value.get("encoding"), AUDIO_ENCODINGS, "encoding") + if "frameDurationMs" in value: + _parse_uint32(value["frameDurationMs"], "frameDurationMs") + + +def _validate_session_body(kind: SessionCommandKind, body: Mapping[str, Any]) -> None: + if kind is SessionCommandKind.INPUT_GATE: + for field_name in ("open", "flushAcceptedAudio"): + if field_name in body and not isinstance(body[field_name], bool): + raise ValueError(f"{field_name} must be a boolean") + elif kind is SessionCommandKind.STOP: + if "reason" in body and not isinstance(body["reason"], str): + raise ValueError("stop.reason must be a string") + elif kind is SessionCommandKind.START: + if "mode" in body: + _known_name(body["mode"], CAPTURE_MODES, "start.mode") + if "source" in body: + VoiceSource.from_dict(_object(body["source"], "start.source")) + if "requestedFormat" in body: + _validate_audio_format(_object(body["requestedFormat"], "start.requestedFormat")) + + +def _is_base64(value: str) -> bool: + pad = len(value) - len(value.rstrip("=")) + if pad > 2: + return False + raw = value[:-pad] if pad else value + if "=" in raw or len(raw) % 4 == 1: + return False + if pad and (pad != (4 - len(raw) % 4) % 4 or len(value) % 4 != 0): + return False + return _STANDARD_BASE64.fullmatch(raw) is not None or _URLSAFE_BASE64.fullmatch(raw) is not None diff --git a/sdks/python/tests/test_conformance.py b/sdks/python/tests/test_conformance.py index 377438b..30b28f1 100644 --- a/sdks/python/tests/test_conformance.py +++ b/sdks/python/tests/test_conformance.py @@ -1,42 +1,92 @@ +import copy import json import sys import unittest from pathlib import Path +from typing import Any PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(PACKAGE_ROOT / "src")) -from murmur_protocol import PayloadKind, RuntimeEvent # noqa: E402 +from murmur_protocol import AudioFrame, RuntimeEvent, SessionControl, VoiceSource # noqa: E402 + +PARSERS = { + "RuntimeEvent": RuntimeEvent.from_dict, + "SessionControl": SessionControl.from_dict, + "AudioFrame": AudioFrame.from_dict, + "VoiceSource": VoiceSource.from_dict, +} +ORDER_FIELDS = { + "RuntimeEvent": "sequence", + "SessionControl": "request_sequence", + "AudioFrame": "sequence", +} + + +def _remove_path(value: dict[str, Any], path: str) -> None: + parts = path.split("/") + current: Any = value + for part in parts[:-1]: + if not isinstance(current, dict) or part not in current: + return + current = current[part] + if isinstance(current, dict): + current.pop(parts[-1], None) class ConformanceTest(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - fixture = Path(__file__).resolve().parents[3] / "conformance/fixtures/runtime-events.jsonl" - cls.lines = [line for line in fixture.read_text(encoding="utf-8").splitlines() if line] - - def test_parses_and_preserves_shared_fixtures(self) -> None: - events = [RuntimeEvent.from_json(line) for line in self.lines] - self.assertEqual(len(events), 7) - self.assertEqual([event.sequence for event in events], list(range(1, 8))) - self.assertEqual(events[3].kind, PayloadKind.TRANSCRIPT) - self.assertEqual(events[3].payload["text"], "synthetic hello") - for event, line in zip(events, self.lines, strict=True): - self.assertEqual(event.to_dict(), json.loads(line)) - - def test_rejects_unknown_or_ambiguous_payloads(self) -> None: - base = { - "protocol": {"major": 1, "minor": 0}, - "sessionId": "test", - "sequence": "1", - "monotonicTimeUs": "1", - } - with self.assertRaises(ValueError): - RuntimeEvent.from_dict({**base, "unknownEvent": {}}) - with self.assertRaises(ValueError): - RuntimeEvent.from_dict( - {**base, "captureReadiness": {"live": True}, "audioLevel": {"amplitude": 0.5}} - ) + def test_manifest(self) -> None: + conformance = REPOSITORY_ROOT / "conformance" + manifest = json.loads((conformance / "manifest.json").read_text(encoding="utf-8")) + for fixture_set in manifest["fixtureSets"]: + path = conformance / fixture_set["path"] + lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line] + self.assertEqual(len(lines), fixture_set["lines"], f"python · {fixture_set['name']} · count") + previous: int | None = None + reject_line = fixture_set.get("rejectLine", 1) + for line_number, line in enumerate(lines, start=1): + label = f"python · {fixture_set['name']} · line {line_number}" + with self.subTest(set=fixture_set["name"], line=line_number): + source = json.loads(line) + try: + parsed = PARSERS[fixture_set["message"]](source) + except (TypeError, ValueError, KeyError) as error: + should_reject = ( + fixture_set["expect"] == "reject" + and fixture_set["rejection"] == "parse" + and line_number >= reject_line + ) + self.assertTrue(should_reject, f"{label} · {error}") + continue + if ( + fixture_set["expect"] == "reject" + and fixture_set["rejection"] == "parse" + and line_number >= reject_line + ): + self.fail(f"{label} · expected {fixture_set['reason']}") + + order_field = ORDER_FIELDS.get(fixture_set["message"]) + if order_field is not None: + current = getattr(parsed, order_field) + ordered = previous is None or current > previous + if ( + fixture_set["expect"] == "reject" + and fixture_set["rejection"] == "order" + and line_number >= reject_line + ): + self.assertFalse(ordered, f"{label} · {fixture_set['reason']}") + else: + self.assertTrue(ordered, f"{label} · ordering") + if ordered: + previous = current + + expected = copy.deepcopy(source) + actual = parsed.to_dict() + for unknown_path in fixture_set.get("unknownFields", []): + _remove_path(expected, unknown_path) + _remove_path(actual, unknown_path) + self.assertEqual(actual, expected, f"{label} · round-trip") if __name__ == "__main__": diff --git a/sdks/rust/murmur-protocol/src/lib.rs b/sdks/rust/murmur-protocol/src/lib.rs index 796b81f..92e2471 100644 --- a/sdks/rust/murmur-protocol/src/lib.rs +++ b/sdks/rust/murmur-protocol/src/lib.rs @@ -2,6 +2,63 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::BTreeMap; +const RUNTIME_ARMS: &[&str] = &[ + "sessionStateChanged", + "captureReadiness", + "audioLevel", + "transcript", + "error", + "intentProposal", + "confirmationRequest", + "actionResult", +]; +const SESSION_ARMS: &[&str] = &["start", "stop", "inputGate", "finalize"]; +const TRANSCRIPT_KINDS: &[&str] = &[ + "TRANSCRIPT_KIND_UNSPECIFIED", + "TRANSCRIPT_KIND_PARTIAL", + "TRANSCRIPT_KIND_FINAL", + "TRANSCRIPT_KIND_REJECTED", +]; +const SESSION_STATES: &[&str] = &[ + "SESSION_STATE_UNSPECIFIED", + "SESSION_STATE_IDLE", + "SESSION_STATE_STARTING", + "SESSION_STATE_LISTENING", + "SESSION_STATE_WARM_MUTED", + "SESSION_STATE_FINALIZING", + "SESSION_STATE_STOPPED", + "SESSION_STATE_ERROR", +]; +const CAPTURE_MODES: &[&str] = &[ + "CAPTURE_MODE_UNSPECIFIED", + "CAPTURE_MODE_TAP_TO_SPEAK", + "CAPTURE_MODE_HOLD_TO_TALK", + "CAPTURE_MODE_HANDS_FREE", + "CAPTURE_MODE_WAKE_PHRASE", +]; +const AUDIO_ENCODINGS: &[&str] = &[ + "AUDIO_ENCODING_PCM_S16LE", + "AUDIO_ENCODING_PCM_F32LE", + "AUDIO_ENCODING_OPUS", +]; +const SOURCE_TRANSPORTS: &[&str] = &[ + "SOURCE_TRANSPORT_BLUETOOTH_LE", + "SOURCE_TRANSPORT_LOCAL_AUDIO", + "SOURCE_TRANSPORT_NETWORK", + "SOURCE_TRANSPORT_FILE", + "SOURCE_TRANSPORT_SYNTHETIC", +]; +const SOURCE_CAPABILITIES: &[&str] = &[ + "SOURCE_CAPABILITY_LIVE_AUDIO", + "SOURCE_CAPABILITY_STORED_AUDIO", + "SOURCE_CAPABILITY_BATTERY", + "SOURCE_CAPABILITY_HARDWARE_CONTROL", + "SOURCE_CAPABILITY_OUTPUT_AUDIO", + "SOURCE_CAPABILITY_BACKGROUND_CAPTURE", + "SOURCE_CAPABILITY_INPUT_MUTE", + "SOURCE_CAPABILITY_SPEAKER_VERIFICATION", +]; + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProtocolVersion { @@ -9,8 +66,14 @@ pub struct ProtocolVersion { pub minor: u32, } +pub const CURRENT_PROTOCOL: ProtocolVersion = ProtocolVersion { major: 1, minor: 0 }; + +pub fn is_supported(protocol: ProtocolVersion) -> bool { + protocol.major == CURRENT_PROTOCOL.major +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", try_from = "RawRuntimeEvent")] pub struct RuntimeEvent { pub protocol: ProtocolVersion, pub session_id: String, @@ -35,17 +98,343 @@ pub enum RuntimePayload { ActionResult(Map), } -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawRuntimeEvent { + protocol: ProtocolVersion, + session_id: String, + #[serde(with = "uint64_string")] + sequence: u64, + #[serde(with = "uint64_string")] + monotonic_time_us: u64, + #[serde(flatten)] + remainder: Map, +} + +impl TryFrom for RuntimeEvent { + type Error = String; + + fn try_from(mut raw: RawRuntimeEvent) -> Result { + validate_envelope(raw.protocol, &raw.session_id)?; + let (arm, body) = take_exactly_one(&mut raw.remainder, RUNTIME_ARMS, "runtime payload")?; + validate_runtime_payload(arm, &body)?; + let payload = match arm { + "sessionStateChanged" => RuntimePayload::SessionStateChanged(body), + "captureReadiness" => RuntimePayload::CaptureReadiness(body), + "audioLevel" => RuntimePayload::AudioLevel(body), + "transcript" => RuntimePayload::Transcript(body), + "error" => RuntimePayload::Error(body), + "intentProposal" => RuntimePayload::IntentProposal(body), + "confirmationRequest" => RuntimePayload::ConfirmationRequest(body), + "actionResult" => RuntimePayload::ActionResult(body), + _ => unreachable!(), + }; + Ok(Self { + protocol: raw.protocol, + session_id: raw.session_id, + sequence: raw.sequence, + monotonic_time_us: raw.monotonic_time_us, + payload, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", try_from = "RawSessionControl")] +pub struct SessionControl { + pub protocol: ProtocolVersion, + pub session_id: String, + #[serde(with = "uint64_string")] + pub request_sequence: u64, + #[serde(flatten)] + pub command: SessionCommand, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SessionCommand { + Start(Map), + Stop(Map), + InputGate(Map), + Finalize(Map), +} + +#[derive(Deserialize)] #[serde(rename_all = "camelCase")] +struct RawSessionControl { + protocol: ProtocolVersion, + session_id: String, + #[serde(with = "uint64_string")] + request_sequence: u64, + #[serde(flatten)] + remainder: Map, +} + +impl TryFrom for SessionControl { + type Error = String; + + fn try_from(mut raw: RawSessionControl) -> Result { + validate_envelope(raw.protocol, &raw.session_id)?; + let (arm, body) = take_exactly_one(&mut raw.remainder, SESSION_ARMS, "session command")?; + validate_session_body(arm, &body)?; + let command = match arm { + "start" => SessionCommand::Start(body), + "stop" => SessionCommand::Stop(body), + "inputGate" => SessionCommand::InputGate(body), + "finalize" => SessionCommand::Finalize(body), + _ => unreachable!(), + }; + Ok(Self { + protocol: raw.protocol, + session_id: raw.session_id, + request_sequence: raw.request_sequence, + command, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", try_from = "RawAudioFrame")] +pub struct AudioFrame { + pub protocol: ProtocolVersion, + pub session_id: String, + #[serde(with = "uint64_string")] + pub sequence: u64, + #[serde(with = "uint64_string")] + pub monotonic_time_us: u64, + pub format: Map, + #[serde(rename = "payload")] + pub payload_base64: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawAudioFrame { + protocol: ProtocolVersion, + session_id: String, + #[serde(with = "uint64_string")] + sequence: u64, + #[serde(with = "uint64_string")] + monotonic_time_us: u64, + format: Map, + #[serde(rename = "payload")] + payload_base64: String, +} + +impl TryFrom for AudioFrame { + type Error = String; + + fn try_from(raw: RawAudioFrame) -> Result { + validate_envelope(raw.protocol, &raw.session_id)?; + validate_audio_format(&raw.format)?; + if !valid_base64(&raw.payload_base64) { + return Err("payload must use valid base64 grammar".into()); + } + Ok(Self { + protocol: raw.protocol, + session_id: raw.session_id, + sequence: raw.sequence, + monotonic_time_us: raw.monotonic_time_us, + format: raw.format, + payload_base64: raw.payload_base64, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", try_from = "RawVoiceSource")] pub struct VoiceSource { pub source_id: String, pub display_name: String, pub transport: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub capabilities: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub metadata: BTreeMap, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawVoiceSource { + source_id: String, + display_name: String, + transport: String, + #[serde(default)] + capabilities: Vec, + #[serde(default)] + metadata: BTreeMap, +} + +impl TryFrom for VoiceSource { + type Error = String; + + fn try_from(raw: RawVoiceSource) -> Result { + if raw.source_id.trim().is_empty() || raw.display_name.trim().is_empty() { + return Err("sourceId and displayName must be non-empty".into()); + } + require_enum(&raw.transport, SOURCE_TRANSPORTS, "transport")?; + for capability in &raw.capabilities { + require_enum(capability, SOURCE_CAPABILITIES, "capability")?; + } + Ok(Self { + source_id: raw.source_id, + display_name: raw.display_name, + transport: raw.transport, + capabilities: raw.capabilities, + metadata: raw.metadata, + }) + } +} + +fn validate_envelope(protocol: ProtocolVersion, session_id: &str) -> Result<(), String> { + if !is_supported(protocol) { + return Err(format!("unsupported protocol major {}", protocol.major)); + } + if session_id.trim().is_empty() { + return Err("sessionId must be non-empty".into()); + } + Ok(()) +} + +fn take_exactly_one( + remainder: &mut Map, + arms: &'static [&'static str], + name: &str, +) -> Result<(&'static str, Map), String> { + let present: Vec<&str> = arms + .iter() + .copied() + .filter(|arm| remainder.contains_key(*arm)) + .collect(); + if present.len() != 1 { + return Err(format!("{name} must contain exactly one known arm")); + } + let arm = present[0]; + let value = remainder.remove(arm).expect("present arm must exist"); + let body = value + .as_object() + .cloned() + .ok_or_else(|| format!("{arm} must be an object"))?; + Ok((arm, body)) +} + +fn require_enum(value: &str, values: &[&str], field: &str) -> Result<(), String> { + if values.contains(&value) { + Ok(()) + } else { + Err(format!("{field} must be a known enum name")) + } +} + +fn value_enum<'a>(body: &'a Map, field: &str) -> Result<&'a str, String> { + body.get(field) + .and_then(Value::as_str) + .ok_or_else(|| format!("{field} must be an enum name")) +} + +fn validate_runtime_payload(arm: &str, body: &Map) -> Result<(), String> { + match arm { + "transcript" => { + require_enum(value_enum(body, "kind")?, TRANSCRIPT_KINDS, "transcript.kind")?; + if !body.get("text").is_some_and(Value::is_string) { + return Err("transcript.text must be a string".into()); + } + } + "audioLevel" => { + let amplitude = body + .get("amplitude") + .and_then(Value::as_f64) + .ok_or("audioLevel.amplitude must be a number")?; + if !(0.0..=1.0).contains(&litude) { + return Err("audioLevel.amplitude must be between 0 and 1".into()); + } + } + "sessionStateChanged" => { + require_enum(value_enum(body, "previous")?, SESSION_STATES, "previous")?; + require_enum(value_enum(body, "current")?, SESSION_STATES, "current")?; + } + _ => {} + } + Ok(()) +} + +fn validate_session_body(arm: &str, body: &Map) -> Result<(), String> { + match arm { + "inputGate" => { + for field in ["open", "flushAcceptedAudio"] { + if body.contains_key(field) && !body[field].is_boolean() { + return Err(format!("{field} must be a boolean")); + } + } + } + "stop" => { + if body.contains_key("reason") && !body["reason"].is_string() { + return Err("stop.reason must be a string".into()); + } + } + "start" => { + if body.contains_key("mode") { + require_enum(value_enum(body, "mode")?, CAPTURE_MODES, "start.mode")?; + } + if let Some(source) = body.get("source") { + serde_json::from_value::(source.clone()).map_err(|error| error.to_string())?; + } + if let Some(format) = body.get("requestedFormat") { + validate_audio_format( + format + .as_object() + .ok_or("start.requestedFormat must be an object")?, + )?; + } + } + _ => {} + } + Ok(()) +} + +fn uint32_field(format: &Map, field: &str) -> Result { + format + .get(field) + .and_then(Value::as_u64) + .filter(|value| *value <= u32::MAX as u64) + .ok_or_else(|| format!("{field} must be a uint32")) +} + +fn validate_audio_format(format: &Map) -> Result<(), String> { + if uint32_field(format, "sampleRateHz")? < 1 { + return Err("sampleRateHz must be at least 1".into()); + } + if uint32_field(format, "channels")? < 1 { + return Err("channels must be at least 1".into()); + } + require_enum(value_enum(format, "encoding")?, AUDIO_ENCODINGS, "encoding")?; + if format.contains_key("frameDurationMs") { + uint32_field(format, "frameDurationMs")?; + } + Ok(()) +} + +fn valid_base64(value: &str) -> bool { + let pad = value.len() - value.trim_end_matches('=').len(); + if pad > 2 { + return false; + } + let raw = &value[..value.len() - pad]; + if raw.contains('=') || raw.len() % 4 == 1 { + return false; + } + if pad > 0 && (pad != (4 - raw.len() % 4) % 4 || value.len() % 4 != 0) { + return false; + } + let standard = raw + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/')); + let url_safe = raw + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); + standard || url_safe +} + mod uint64_string { use serde::{de::Error, Deserialize, Deserializer, Serializer}; @@ -61,6 +450,9 @@ mod uint64_string { D: Deserializer<'de>, { let value = String::deserialize(deserializer)?; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(D::Error::custom("uint64 must contain ASCII digits only")); + } value.parse().map_err(D::Error::custom) } } @@ -68,30 +460,144 @@ mod uint64_string { #[cfg(test)] mod tests { use super::*; + use std::{fs, path::PathBuf}; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Manifest { + fixture_sets: Vec, + } - const FIXTURES: &str = include_str!("../../../../conformance/fixtures/runtime-events.jsonl"); + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct FixtureSet { + name: String, + message: String, + path: String, + lines: usize, + expect: String, + reason: Option, + rejection: Option, + reject_line: Option, + #[serde(default)] + unknown_fields: Vec, + } + + enum Parsed { + Runtime(RuntimeEvent), + Session(SessionControl), + Audio(AudioFrame), + Source(VoiceSource), + } + + impl Parsed { + fn parse(message: &str, value: Value) -> serde_json::Result { + match message { + "RuntimeEvent" => serde_json::from_value(value).map(Self::Runtime), + "SessionControl" => serde_json::from_value(value).map(Self::Session), + "AudioFrame" => serde_json::from_value(value).map(Self::Audio), + "VoiceSource" => serde_json::from_value(value).map(Self::Source), + _ => panic!("unknown fixture message {message}"), + } + } + + fn ordering_key(&self) -> Option { + match self { + Self::Runtime(value) => Some(value.sequence), + Self::Session(value) => Some(value.request_sequence), + Self::Audio(value) => Some(value.sequence), + Self::Source(_) => None, + } + } + + fn to_value(&self) -> Value { + match self { + Self::Runtime(value) => serde_json::to_value(value), + Self::Session(value) => serde_json::to_value(value), + Self::Audio(value) => serde_json::to_value(value), + Self::Source(value) => serde_json::to_value(value), + } + .expect("parsed fixture must serialize") + } + } + + fn remove_path(value: &mut Value, path: &str) { + let mut parts = path.split('/').peekable(); + let mut current = value; + while let Some(part) = parts.next() { + if parts.peek().is_none() { + if let Some(object) = current.as_object_mut() { + object.remove(part); + } + return; + } + match current.as_object_mut().and_then(|object| object.get_mut(part)) { + Some(next) => current = next, + None => return, + } + } + } #[test] - fn parses_and_preserves_shared_fixtures() { - let source: Vec = FIXTURES - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| serde_json::from_str(line).expect("fixture must be JSON")) - .collect(); - let events: Vec = FIXTURES - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| serde_json::from_str(line).expect("fixture must conform")) - .collect(); - - assert_eq!(events.len(), 7); - assert_eq!(events[3].sequence, 4); - assert!(matches!(events[3].payload, RuntimePayload::Transcript(_))); - - let encoded: Vec = events - .iter() - .map(|event| serde_json::to_value(event).expect("event must serialize")) - .collect(); - assert_eq!(encoded, source); + fn passes_every_manifest_conformance_set() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let conformance = root.join("conformance"); + let manifest: Manifest = serde_json::from_str( + &fs::read_to_string(conformance.join("manifest.json")).expect("manifest must be readable"), + ) + .expect("manifest must be valid"); + for fixture_set in manifest.fixture_sets { + let source = fs::read_to_string(conformance.join(&fixture_set.path)).expect("fixture must be readable"); + let lines: Vec<&str> = source.lines().filter(|line| !line.trim().is_empty()).collect(); + assert_eq!( + lines.len(), + fixture_set.lines, + "rust · {} · count", + fixture_set.name + ); + let mut previous = None; + let reject_line = fixture_set.reject_line.unwrap_or(1); + for (index, line) in lines.iter().enumerate() { + let line_number = index + 1; + let label = format!("rust · {} · line {line_number}", fixture_set.name); + let input: Value = serde_json::from_str(line).expect("fixture line must be JSON"); + let parsed = match Parsed::parse(&fixture_set.message, input.clone()) { + Ok(value) => value, + Err(error) => { + let should_reject = fixture_set.expect == "reject" + && fixture_set.rejection.as_deref() == Some("parse") + && line_number >= reject_line; + assert!(should_reject, "{label} · {error}"); + continue; + } + }; + if fixture_set.expect == "reject" + && fixture_set.rejection.as_deref() == Some("parse") + && line_number >= reject_line + { + panic!( + "{label} · expected {}", + fixture_set.reason.as_deref().unwrap_or("rejection") + ); + } + if let Some(current) = parsed.ordering_key() { + let ordered = previous.map_or(true, |value| current > value); + let rejects_order = fixture_set.expect == "reject" + && fixture_set.rejection.as_deref() == Some("order") + && line_number >= reject_line; + assert_eq!(ordered, !rejects_order, "{label} · ordering"); + if ordered { + previous = Some(current); + } + } + let mut expected = input; + let mut actual = parsed.to_value(); + for path in &fixture_set.unknown_fields { + remove_path(&mut expected, path); + remove_path(&mut actual, path); + } + assert_eq!(actual, expected, "{label} · round-trip"); + } + } } } diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 6214686..43914e3 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -3,6 +3,13 @@ export interface ProtocolVersion { minor: number } +export const currentProtocol: ProtocolVersion = { major: 1, minor: 0 } +const uint64Max = (1n << 64n) - 1n + +export function isSupported(protocol: ProtocolVersion): boolean { + return protocol.major === currentProtocol.major +} + export const runtimePayloadKinds = [ 'sessionStateChanged', 'captureReadiness', @@ -13,19 +20,40 @@ export const runtimePayloadKinds = [ 'confirmationRequest', 'actionResult' ] as const - export type RuntimePayloadKind = (typeof runtimePayloadKinds)[number] export type RuntimePayload = Readonly> -export interface RuntimeEvent { - protocol: ProtocolVersion - sessionId: string - sequence: bigint - monotonicTimeUs: bigint - kind: RuntimePayloadKind - payload: RuntimePayload -} +export const sessionCommandKinds = ['start', 'stop', 'inputGate', 'finalize'] as const +export type SessionCommandKind = (typeof sessionCommandKinds)[number] +export const transcriptKinds = [ + 'TRANSCRIPT_KIND_UNSPECIFIED', + 'TRANSCRIPT_KIND_PARTIAL', + 'TRANSCRIPT_KIND_FINAL', + 'TRANSCRIPT_KIND_REJECTED' +] as const +export const sessionStates = [ + 'SESSION_STATE_UNSPECIFIED', + 'SESSION_STATE_IDLE', + 'SESSION_STATE_STARTING', + 'SESSION_STATE_LISTENING', + 'SESSION_STATE_WARM_MUTED', + 'SESSION_STATE_FINALIZING', + 'SESSION_STATE_STOPPED', + 'SESSION_STATE_ERROR' +] as const +export const captureModes = [ + 'CAPTURE_MODE_UNSPECIFIED', + 'CAPTURE_MODE_TAP_TO_SPEAK', + 'CAPTURE_MODE_HOLD_TO_TALK', + 'CAPTURE_MODE_HANDS_FREE', + 'CAPTURE_MODE_WAKE_PHRASE' +] as const +export const audioEncodings = [ + 'AUDIO_ENCODING_PCM_S16LE', + 'AUDIO_ENCODING_PCM_F32LE', + 'AUDIO_ENCODING_OPUS' +] as const export const voiceSourceTransports = [ 'SOURCE_TRANSPORT_BLUETOOTH_LE', 'SOURCE_TRANSPORT_LOCAL_AUDIO', @@ -33,35 +61,69 @@ export const voiceSourceTransports = [ 'SOURCE_TRANSPORT_FILE', 'SOURCE_TRANSPORT_SYNTHETIC' ] as const +export const voiceSourceCapabilities = [ + 'SOURCE_CAPABILITY_LIVE_AUDIO', + 'SOURCE_CAPABILITY_STORED_AUDIO', + 'SOURCE_CAPABILITY_BATTERY', + 'SOURCE_CAPABILITY_HARDWARE_CONTROL', + 'SOURCE_CAPABILITY_OUTPUT_AUDIO', + 'SOURCE_CAPABILITY_BACKGROUND_CAPTURE', + 'SOURCE_CAPABILITY_INPUT_MUTE', + 'SOURCE_CAPABILITY_SPEAKER_VERIFICATION' +] as const export type VoiceSourceTransport = (typeof voiceSourceTransports)[number] +export type VoiceSourceCapability = (typeof voiceSourceCapabilities)[number] + +export interface RuntimeEvent { + protocol: ProtocolVersion + sessionId: string + sequence: bigint + monotonicTimeUs: bigint + kind: RuntimePayloadKind + payload: RuntimePayload +} + +export interface SessionControl { + protocol: ProtocolVersion + sessionId: string + requestSequence: bigint + kind: SessionCommandKind + body: Readonly> +} + +export interface AudioFrame { + protocol: ProtocolVersion + sessionId: string + sequence: bigint + monotonicTimeUs: bigint + format: Readonly> + payloadBase64: string +} export interface VoiceSource { sourceId: string displayName: string transport: VoiceSourceTransport - capabilities: readonly string[] + capabilities: readonly VoiceSourceCapability[] metadata?: Readonly> } export function parseRuntimeEvent(input: unknown): RuntimeEvent { const object = asObject(input, 'runtime event') - const protocolObject = asObject(object.protocol, 'protocol') - const protocol: ProtocolVersion = { - major: asNonNegativeInteger(protocolObject.major, 'protocol.major'), - minor: asNonNegativeInteger(protocolObject.minor, 'protocol.minor') - } - const sessionId = asNonEmptyString(object.sessionId, 'sessionId') - const sequence = asUint64(object.sequence, 'sequence') - const monotonicTimeUs = asUint64(object.monotonicTimeUs, 'monotonicTimeUs') const present = runtimePayloadKinds.filter((field) => Object.hasOwn(object, field)) - if (present.length !== 1) { - throw new TypeError('runtime event must contain exactly one known payload') - } + if (present.length !== 1) throw new TypeError('runtime event must contain exactly one known payload') const kind = present[0] const payload = asObject(object[kind], kind) - validatePayload(kind, payload) - return { protocol, sessionId, sequence, monotonicTimeUs, kind, payload } + validateRuntimePayload(kind, payload) + return { + protocol: parseProtocol(object.protocol), + sessionId: asNonEmptyString(object.sessionId, 'sessionId'), + sequence: asUint64(object.sequence, 'sequence'), + monotonicTimeUs: asUint64(object.monotonicTimeUs, 'monotonicTimeUs'), + kind, + payload + } } export function parseRuntimeEventJson(source: string): RuntimeEvent { @@ -78,6 +140,107 @@ export function runtimeEventToJson(event: RuntimeEvent): Record } } +export function parseSessionControl(input: unknown): SessionControl { + const object = asObject(input, 'session control') + const present = sessionCommandKinds.filter((field) => Object.hasOwn(object, field)) + if (present.length !== 1) throw new TypeError('session control must contain exactly one known command') + const kind = present[0] + const body = asObject(object[kind], kind) + validateSessionBody(kind, body) + return { + protocol: parseProtocol(object.protocol), + sessionId: asNonEmptyString(object.sessionId, 'sessionId'), + requestSequence: asUint64(object.requestSequence, 'requestSequence'), + kind, + body + } +} + +export function sessionControlToJson(control: SessionControl): Record { + return { + protocol: control.protocol, + sessionId: control.sessionId, + requestSequence: control.requestSequence.toString(), + [control.kind]: control.body + } +} + +export function parseAudioFrame(input: unknown): AudioFrame { + const object = asObject(input, 'audio frame') + const format = asObject(object.format, 'format') + validateAudioFormat(format) + const payloadBase64 = object.payload + if (typeof payloadBase64 !== 'string' || !isBase64(payloadBase64)) { + throw new TypeError('payload must use valid base64 grammar') + } + return { + protocol: parseProtocol(object.protocol), + sessionId: asNonEmptyString(object.sessionId, 'sessionId'), + sequence: asUint64(object.sequence, 'sequence'), + monotonicTimeUs: asUint64(object.monotonicTimeUs, 'monotonicTimeUs'), + format, + payloadBase64 + } +} + +export function audioFrameToJson(frame: AudioFrame): Record { + return { + protocol: frame.protocol, + sessionId: frame.sessionId, + sequence: frame.sequence.toString(), + monotonicTimeUs: frame.monotonicTimeUs.toString(), + format: frame.format, + payload: frame.payloadBase64 + } +} + +export function parseVoiceSource(input: unknown): VoiceSource { + const object = asObject(input, 'voice source') + const rawCapabilities = object.capabilities ?? [] + if (!Array.isArray(rawCapabilities)) throw new TypeError('capabilities must be an array') + const capabilities = rawCapabilities.map((value) => + asEnum(value, voiceSourceCapabilities, 'capability') + ) + const rawMetadata = object.metadata ?? {} + const metadataObject = asObject(rawMetadata, 'metadata') + const metadata = Object.fromEntries( + Object.entries(metadataObject).map(([key, value]) => { + if (typeof value !== 'string') throw new TypeError('metadata values must be strings') + return [key, value] as const + }) + ) + return { + sourceId: asNonEmptyString(object.sourceId, 'sourceId'), + displayName: asNonEmptyString(object.displayName, 'displayName'), + transport: asEnum(object.transport, voiceSourceTransports, 'transport'), + capabilities, + metadata + } +} + +export function voiceSourceToJson(source: VoiceSource): Record { + const result: Record = { + sourceId: source.sourceId, + displayName: source.displayName, + transport: source.transport + } + if (source.capabilities.length > 0) result.capabilities = source.capabilities + if (source.metadata !== undefined && Object.keys(source.metadata).length > 0) { + result.metadata = source.metadata + } + return result +} + +function parseProtocol(input: unknown): ProtocolVersion { + const object = asObject(input, 'protocol') + const protocol = { + major: asUint32(object.major, 'protocol.major'), + minor: asUint32(object.minor, 'protocol.minor') + } + if (!isSupported(protocol)) throw new TypeError(`unsupported protocol major ${protocol.major}`) + return protocol +} + function asObject(input: unknown, field: string): Record { if (typeof input !== 'object' || input === null || Array.isArray(input)) { throw new TypeError(`${field} must be an object`) @@ -92,29 +255,73 @@ function asNonEmptyString(input: unknown, field: string): string { return input } -function asNonNegativeInteger(input: unknown, field: string): number { - if (typeof input !== 'number' || !Number.isSafeInteger(input) || input < 0) { - throw new TypeError(`${field} must be a non-negative integer`) +function asUint32(input: unknown, field: string): number { + if (typeof input !== 'number' || !Number.isInteger(input) || input < 0 || input > 0xffff_ffff) { + throw new TypeError(`${field} must be a uint32`) } return input } function asUint64(input: unknown, field: string): bigint { - if (typeof input !== 'string' || !/^\d+$/.test(input)) { - throw new TypeError(`${field} must be a uint64 string`) + if (typeof input !== 'string' || !/^[0-9]+$/.test(input)) { + throw new TypeError(`${field} must be an ASCII uint64 string`) + } + const parsed = BigInt(input) + if (parsed > uint64Max) throw new TypeError(`${field} is outside uint64 range`) + return parsed +} + +function asEnum(input: unknown, values: T, field: string): T[number] { + if (typeof input !== 'string' || !values.includes(input)) { + throw new TypeError(`${field} must be a known enum name`) } - return BigInt(input) + return input as T[number] } -function validatePayload(kind: RuntimePayloadKind, payload: Record): void { +function validateRuntimePayload(kind: RuntimePayloadKind, payload: Record): void { if (kind === 'transcript') { - asNonEmptyString(payload.kind, 'transcript.kind') + asEnum(payload.kind, transcriptKinds, 'transcript.kind') if (typeof payload.text !== 'string') throw new TypeError('transcript.text must be a string') - } - if (kind === 'audioLevel') { + } else if (kind === 'audioLevel') { const amplitude = payload.amplitude if (typeof amplitude !== 'number' || amplitude < 0 || amplitude > 1) { throw new TypeError('audioLevel.amplitude must be between 0 and 1') } + } else if (kind === 'sessionStateChanged') { + asEnum(payload.previous, sessionStates, 'sessionStateChanged.previous') + asEnum(payload.current, sessionStates, 'sessionStateChanged.current') + } +} + +function validateAudioFormat(format: Record): void { + if (asUint32(format.sampleRateHz, 'sampleRateHz') < 1) throw new TypeError('sampleRateHz must be at least 1') + if (asUint32(format.channels, 'channels') < 1) throw new TypeError('channels must be at least 1') + asEnum(format.encoding, audioEncodings, 'encoding') + if (Object.hasOwn(format, 'frameDurationMs')) asUint32(format.frameDurationMs, 'frameDurationMs') +} + +function validateSessionBody(kind: SessionCommandKind, body: Record): void { + if (kind === 'inputGate') { + for (const field of ['open', 'flushAcceptedAudio']) { + if (Object.hasOwn(body, field) && typeof body[field] !== 'boolean') { + throw new TypeError(`${field} must be a boolean`) + } + } + } else if (kind === 'stop' && Object.hasOwn(body, 'reason') && typeof body.reason !== 'string') { + throw new TypeError('stop.reason must be a string') + } else if (kind === 'start') { + if (Object.hasOwn(body, 'mode')) asEnum(body.mode, captureModes, 'start.mode') + if (Object.hasOwn(body, 'source')) parseVoiceSource(body.source) + if (Object.hasOwn(body, 'requestedFormat')) validateAudioFormat(asObject(body.requestedFormat, 'requestedFormat')) } } + +function isBase64(value: string): boolean { + const match = /=*$/.exec(value) + const pad = match?.[0].length ?? 0 + if (pad > 2) return false + const raw = pad === 0 ? value : value.slice(0, -pad) + if (raw.includes('=') || raw.length % 4 === 1) return false + if (pad > 0 && (pad !== (4 - (raw.length % 4)) % 4 || value.length % 4 !== 0)) return false + return /^[A-Za-z0-9+/]*$/.test(raw) || /^[A-Za-z0-9_-]*$/.test(raw) +} diff --git a/sdks/typescript/test/conformance.test.ts b/sdks/typescript/test/conformance.test.ts index 1100aae..263a2ed 100644 --- a/sdks/typescript/test/conformance.test.ts +++ b/sdks/typescript/test/conformance.test.ts @@ -2,37 +2,118 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { parseRuntimeEventJson, runtimeEventToJson } from '../src/index.ts' - -const fixtureUrl = new URL('../../../conformance/fixtures/runtime-events.jsonl', import.meta.url) -const lines = readFileSync(fixtureUrl, 'utf8').trim().split('\n') - -test('parses and preserves all shared runtime fixtures', () => { - const events = lines.map(parseRuntimeEventJson) - assert.equal(events.length, 7) - assert.deepEqual( - events.map((event) => event.sequence), - [1n, 2n, 3n, 4n, 5n, 6n, 7n] - ) - assert.equal(events[3]?.kind, 'transcript') - assert.equal(events[3]?.payload.text, 'synthetic hello') - - events.forEach((event, index) => { - assert.deepEqual(runtimeEventToJson(event), JSON.parse(lines[index] ?? '')) - }) -}) +import { + audioFrameToJson, + parseAudioFrame, + parseRuntimeEvent, + parseSessionControl, + parseVoiceSource, + runtimeEventToJson, + sessionControlToJson, + voiceSourceToJson, + type AudioFrame, + type RuntimeEvent, + type SessionControl, + type VoiceSource +} from '../src/index.ts' + +type Message = 'RuntimeEvent' | 'SessionControl' | 'AudioFrame' | 'VoiceSource' +type Parsed = RuntimeEvent | SessionControl | AudioFrame | VoiceSource +interface FixtureSet { + name: string + message: Message + path: string + lines: number + expect: 'accept' | 'reject' + reason?: string + rejection?: 'parse' | 'order' + rejectLine?: number + unknownFields?: string[] +} + +const conformanceUrl = new URL('../../../conformance/', import.meta.url) +const manifest = JSON.parse(readFileSync(new URL('manifest.json', conformanceUrl), 'utf8')) as { + fixtureSets: FixtureSet[] +} + +function parse(message: Message, input: unknown): Parsed { + switch (message) { + case 'RuntimeEvent': return parseRuntimeEvent(input) + case 'SessionControl': return parseSessionControl(input) + case 'AudioFrame': return parseAudioFrame(input) + case 'VoiceSource': return parseVoiceSource(input) + } +} + +function serialize(message: Message, value: Parsed): Record { + switch (message) { + case 'RuntimeEvent': return runtimeEventToJson(value as RuntimeEvent) + case 'SessionControl': return sessionControlToJson(value as SessionControl) + case 'AudioFrame': return audioFrameToJson(value as AudioFrame) + case 'VoiceSource': return voiceSourceToJson(value as VoiceSource) + } +} + +function orderingKey(message: Message, value: Parsed): bigint | undefined { + switch (message) { + case 'RuntimeEvent': return (value as RuntimeEvent).sequence + case 'SessionControl': return (value as SessionControl).requestSequence + case 'AudioFrame': return (value as AudioFrame).sequence + case 'VoiceSource': return undefined + } +} + +function removePath(value: Record, path: string): void { + const parts = path.split('/') + let current: unknown = value + for (const part of parts.slice(0, -1)) { + if (typeof current !== 'object' || current === null || Array.isArray(current)) return + current = (current as Record)[part] + } + if (typeof current === 'object' && current !== null && !Array.isArray(current)) { + delete (current as Record)[parts.at(-1) ?? ''] + } +} + +test('all manifest conformance sets', () => { + for (const fixtureSet of manifest.fixtureSets) { + const lines = readFileSync(new URL(fixtureSet.path, conformanceUrl), 'utf8') + .split('\n') + .filter((line) => line.trim() !== '') + assert.equal(lines.length, fixtureSet.lines, `typescript · ${fixtureSet.name} · count`) + let previous: bigint | undefined + const rejectLine = fixtureSet.rejectLine ?? 1 + lines.forEach((line, index) => { + const lineNumber = index + 1 + const label = `typescript · ${fixtureSet.name} · line ${lineNumber}` + const input = JSON.parse(line) as Record + let parsed: Parsed + try { + parsed = parse(fixtureSet.message, input) + } catch (error) { + const shouldReject = fixtureSet.expect === 'reject' && fixtureSet.rejection === 'parse' && lineNumber >= rejectLine + assert.ok(shouldReject, `${label} · ${String(error)}`) + return + } + if (fixtureSet.expect === 'reject' && fixtureSet.rejection === 'parse' && lineNumber >= rejectLine) { + assert.fail(`${label} · expected ${fixtureSet.reason ?? 'rejection'}`) + } + + const current = orderingKey(fixtureSet.message, parsed) + if (current !== undefined) { + const ordered = previous === undefined || current > previous + const rejectsOrder = fixtureSet.expect === 'reject' && fixtureSet.rejection === 'order' && lineNumber >= rejectLine + assert.equal(ordered, !rejectsOrder, `${label} · ${fixtureSet.reason ?? 'ordering'}`) + if (ordered) previous = current + } -test('rejects unknown and ambiguous payloads', () => { - const base = { - protocol: { major: 1, minor: 0 }, - sessionId: 'test', - sequence: '1', - monotonicTimeUs: '1' + const expected = structuredClone(input) + const actual = structuredClone(serialize(fixtureSet.message, parsed)) + for (const path of fixtureSet.unknownFields ?? []) { + removePath(expected, path) + removePath(actual, path) + } + assert.deepEqual(actual, expected, `${label} · round-trip`) + }) } - assert.throws(() => parseRuntimeEventJson(JSON.stringify({ ...base, unknownEvent: {} }))) - assert.throws(() => - parseRuntimeEventJson( - JSON.stringify({ ...base, captureReadiness: { live: true }, audioLevel: { amplitude: 0.5 } }) - ) - ) }) diff --git a/tool/check_conformance.py b/tool/check_conformance.py index 3724c99..8927c9d 100644 --- a/tool/check_conformance.py +++ b/tool/check_conformance.py @@ -1,57 +1,324 @@ #!/usr/bin/env python3 -"""Validate shared fixtures without requiring a language SDK.""" +"""Validate the shared conformance corpus without requiring an SDK.""" from __future__ import annotations +import base64 import json +import re from pathlib import Path - +from typing import Any ROOT = Path(__file__).resolve().parents[1] -PAYLOAD_FIELDS = { - "sessionStateChanged", - "captureReadiness", - "audioLevel", - "transcript", - "error", - "intentProposal", - "confirmationRequest", - "actionResult", +UINT32_MAX = 2**32 - 1 +UINT64_MAX = 2**64 - 1 +MESSAGES = {"RuntimeEvent", "SessionControl", "AudioFrame", "VoiceSource"} +REASONS = { + "missing-payload", "ambiguous-oneof", "missing-session-command", + "ambiguous-session-command", "invalid-enum", "sequence-order", + "invalid-uint64", "unsupported-protocol-major", + "invalid-protocol-version", "invalid-audio-frame", +} +RUNTIME_ARMS = { + "sessionStateChanged", "captureReadiness", "audioLevel", "transcript", + "error", "intentProposal", "confirmationRequest", "actionResult", +} +SESSION_ARMS = {"start", "stop", "inputGate", "finalize"} +TRANSCRIPT_KINDS = { + "TRANSCRIPT_KIND_UNSPECIFIED", "TRANSCRIPT_KIND_PARTIAL", + "TRANSCRIPT_KIND_FINAL", "TRANSCRIPT_KIND_REJECTED", +} +SESSION_STATES = { + "SESSION_STATE_UNSPECIFIED", "SESSION_STATE_IDLE", "SESSION_STATE_STARTING", + "SESSION_STATE_LISTENING", "SESSION_STATE_WARM_MUTED", + "SESSION_STATE_FINALIZING", "SESSION_STATE_STOPPED", "SESSION_STATE_ERROR", +} +CAPTURE_MODES = { + "CAPTURE_MODE_UNSPECIFIED", "CAPTURE_MODE_TAP_TO_SPEAK", + "CAPTURE_MODE_HOLD_TO_TALK", "CAPTURE_MODE_HANDS_FREE", + "CAPTURE_MODE_WAKE_PHRASE", +} +AUDIO_ENCODINGS = { + "AUDIO_ENCODING_PCM_S16LE", "AUDIO_ENCODING_PCM_F32LE", "AUDIO_ENCODING_OPUS", +} +SOURCE_TRANSPORTS = { + "SOURCE_TRANSPORT_BLUETOOTH_LE", "SOURCE_TRANSPORT_LOCAL_AUDIO", + "SOURCE_TRANSPORT_NETWORK", "SOURCE_TRANSPORT_FILE", "SOURCE_TRANSPORT_SYNTHETIC", +} +SOURCE_CAPABILITIES = { + "SOURCE_CAPABILITY_LIVE_AUDIO", "SOURCE_CAPABILITY_STORED_AUDIO", + "SOURCE_CAPABILITY_BATTERY", "SOURCE_CAPABILITY_HARDWARE_CONTROL", + "SOURCE_CAPABILITY_OUTPUT_AUDIO", "SOURCE_CAPABILITY_BACKGROUND_CAPTURE", + "SOURCE_CAPABILITY_INPUT_MUTE", "SOURCE_CAPABILITY_SPEAKER_VERIFICATION", +} +KNOWN_FIELDS = { + "RuntimeEvent": {"protocol", "sessionId", "sequence", "monotonicTimeUs"} | RUNTIME_ARMS, + "SessionControl": {"protocol", "sessionId", "requestSequence"} | SESSION_ARMS, + "AudioFrame": {"protocol", "sessionId", "sequence", "monotonicTimeUs", "format", "payload"}, + "VoiceSource": {"sourceId", "displayName", "transport", "capabilities", "metadata"}, + "transcript": {"kind", "text", "providerId", "confidence"}, } +ASCII_UINT = re.compile(r"^[0-9]+$") +STANDARD_BASE64 = re.compile(r"^[A-Za-z0-9+/]*$") +URLSAFE_BASE64 = re.compile(r"^[A-Za-z0-9_-]*$") + + +def is_uint32(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= UINT32_MAX + + +def is_uint64_string(value: Any) -> bool: + return isinstance(value, str) and ASCII_UINT.fullmatch(value) is not None and int(value) <= UINT64_MAX + + +def is_base64(value: Any) -> bool: + if not isinstance(value, str): + return False + pad = len(value) - len(value.rstrip("=")) + if pad > 2: + return False + raw = value[:-pad] if pad else value + if "=" in raw: + return False + remainder = len(raw) % 4 + if remainder == 1: + return False + if pad and (pad != (4 - remainder) % 4 or len(value) % 4 != 0): + return False + return STANDARD_BASE64.fullmatch(raw) is not None or URLSAFE_BASE64.fullmatch(raw) is not None + + +def valid_protocol(value: Any) -> tuple[bool, bool]: + if not isinstance(value, dict) or not is_uint32(value.get("major")) or not is_uint32(value.get("minor")): + return False, False + return True, value["major"] == 1 + + +def valid_source(value: Any) -> bool: + if not isinstance(value, dict): + return False + source_id, display_name = value.get("sourceId"), value.get("displayName") + capabilities, metadata = value.get("capabilities", []), value.get("metadata", {}) + return ( + isinstance(source_id, str) and bool(source_id.strip()) + and isinstance(display_name, str) and bool(display_name.strip()) + and value.get("transport") in SOURCE_TRANSPORTS + and isinstance(capabilities, list) + and all(capability in SOURCE_CAPABILITIES for capability in capabilities) + and isinstance(metadata, dict) + and all(isinstance(key, str) and isinstance(item, str) for key, item in metadata.items()) + ) + + +def valid_audio_format(value: Any) -> bool: + return ( + isinstance(value, dict) + and is_uint32(value.get("sampleRateHz")) and value["sampleRateHz"] >= 1 + and is_uint32(value.get("channels")) and value["channels"] >= 1 + and value.get("encoding") in AUDIO_ENCODINGS + and ("frameDurationMs" not in value or is_uint32(value["frameDurationMs"])) + ) + + +def common_violations(value: dict[str, Any], sequence_field: str) -> set[str]: + result: set[str] = set() + protocol_valid, protocol_supported = valid_protocol(value.get("protocol")) + if not protocol_valid: + result.add("invalid-protocol-version") + elif not protocol_supported: + result.add("unsupported-protocol-major") + if not isinstance(value.get("sessionId"), str) or not value["sessionId"].strip(): + result.add("invalid-protocol-version") + if not is_uint64_string(value.get(sequence_field)): + result.add("invalid-uint64") + return result + + +def diagnose(message: str, value: Any) -> set[str]: + if not isinstance(value, dict): + return {"invalid-protocol-version"} + if message == "VoiceSource": + return set() if valid_source(value) else {"invalid-enum"} + if message == "RuntimeEvent": + result = common_violations(value, "sequence") + if not is_uint64_string(value.get("monotonicTimeUs")): + result.add("invalid-uint64") + present = RUNTIME_ARMS.intersection(value) + if not present: + result.add("missing-payload") + return result + if len(present) > 1: + result.add("ambiguous-oneof") + return result + arm = next(iter(present)) + payload = value[arm] + if not isinstance(payload, dict): + result.add("invalid-enum") + return result + if arm == "transcript" and ( + payload.get("kind") not in TRANSCRIPT_KINDS or not isinstance(payload.get("text"), str) + ): + result.add("invalid-enum") + elif arm == "audioLevel": + amplitude = payload.get("amplitude") + if isinstance(amplitude, bool) or not isinstance(amplitude, (int, float)) or not 0 <= amplitude <= 1: + result.add("invalid-enum") + elif arm == "sessionStateChanged" and ( + payload.get("previous") not in SESSION_STATES or payload.get("current") not in SESSION_STATES + ): + result.add("invalid-enum") + return result + if message == "SessionControl": + result = common_violations(value, "requestSequence") + present = SESSION_ARMS.intersection(value) + if not present: + result.add("missing-session-command") + return result + if len(present) > 1: + result.add("ambiguous-session-command") + return result + arm = next(iter(present)) + body = value[arm] + if not isinstance(body, dict): + result.add("invalid-enum") + return result + if arm == "inputGate" and any( + field in body and not isinstance(body[field], bool) for field in ("open", "flushAcceptedAudio") + ): + result.add("invalid-enum") + if arm == "stop" and "reason" in body and not isinstance(body["reason"], str): + result.add("invalid-enum") + if arm == "start": + if "mode" in body and body["mode"] not in CAPTURE_MODES: + result.add("invalid-enum") + if "source" in body and not valid_source(body["source"]): + result.add("invalid-enum") + if "requestedFormat" in body and not valid_audio_format(body["requestedFormat"]): + result.add("invalid-enum") + return result + if message == "AudioFrame": + result = common_violations(value, "sequence") + if not is_uint64_string(value.get("monotonicTimeUs")): + result.add("invalid-uint64") + if not valid_audio_format(value.get("format")) or not is_base64(value.get("payload")): + result.add("invalid-audio-frame") + return result + raise AssertionError(f"unknown message {message}") + + +def get_path(value: dict[str, Any], path: str) -> Any: + current: Any = value + for part in path.split("/"): + if not isinstance(current, dict) or part not in current: + return None + current = current[part] + return current + + +def validate_pcm_length(value: dict[str, Any], location: str) -> None: + audio_format = value["format"] + bytes_per_sample = {"AUDIO_ENCODING_PCM_S16LE": 2, "AUDIO_ENCODING_PCM_F32LE": 4}.get( + audio_format["encoding"] + ) + duration = audio_format.get("frameDurationMs") + if bytes_per_sample is None or duration is None: + return + expected = audio_format["sampleRateHz"] * audio_format["channels"] * duration * bytes_per_sample + assert expected % 1000 == 0, f"{location}: PCM duration does not produce whole bytes" + decoded = base64.b64decode(value["payload"], validate=True) + assert len(decoded) == expected // 1000, f"{location}: incorrect PCM payload length" + + +def validate_manifest(manifest: Any) -> list[dict[str, Any]]: + assert isinstance(manifest, dict), "manifest must be an object" + assert manifest.get("manifestVersion") == 1, "unsupported manifest version" + assert manifest.get("protocol") == {"major": 1, "minor": 0}, "unexpected conformance protocol" + fixture_sets = manifest.get("fixtureSets") + assert isinstance(fixture_sets, list) and fixture_sets, "fixtureSets must be non-empty" + names: set[str] = set() + for item in fixture_sets: + assert isinstance(item, dict), "fixture set must be an object" + required = {"name", "message", "path", "lines", "expect"} + assert required <= item.keys(), f"fixture set is missing {required - item.keys()}" + assert isinstance(item["name"], str) and item["name"] not in names, "fixture names must be unique" + names.add(item["name"]) + assert item["message"] in MESSAGES, f"invalid message in {item['name']}" + assert item["expect"] in {"accept", "reject"}, f"invalid expectation in {item['name']}" + assert isinstance(item["lines"], int) and item["lines"] > 0, f"invalid line count in {item['name']}" + if item["expect"] == "reject": + assert item.get("reason") in REASONS, f"invalid reason in {item['name']}" + assert item.get("rejection") in {"parse", "order"}, f"invalid rejection in {item['name']}" + reject_line = item.get("rejectLine", 1) + assert isinstance(reject_line, int) and 1 <= reject_line <= item["lines"] + unknown = item.get("unknownFields", []) + assert isinstance(unknown, list) and all(isinstance(path, str) and path for path in unknown) + return fixture_sets def main() -> None: manifest = json.loads((ROOT / "conformance/manifest.json").read_text(encoding="utf-8")) + fixture_sets = validate_manifest(manifest) protocol = manifest["protocol"] - assert protocol == {"major": 1, "minor": 0}, "unexpected conformance protocol version" - - for fixture_set in manifest["fixtureSets"]: + for fixture_set in fixture_sets: path = ROOT / "conformance" / fixture_set["path"] lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line] - assert len(lines) == fixture_set["events"], f"incorrect event count in {path}" - previous_sequence = 0 + assert len(lines) == fixture_set["lines"], f"incorrect line count in {path}" + values = [json.loads(line) for line in lines] + message = fixture_set["message"] + reject_line = fixture_set.get("rejectLine", 1) + previous: int | None = None session_id: str | None = None - for line_number, line in enumerate(lines, start=1): - event = json.loads(line) - assert event["protocol"] == protocol, f"protocol mismatch at {path}:{line_number}" - assert isinstance(event["sequence"], str), f"sequence must be a string at {path}:{line_number}" - assert isinstance(event["monotonicTimeUs"], str), ( - f"monotonicTimeUs must be a string at {path}:{line_number}" + for line_number, value in enumerate(values, start=1): + location = f"{path}:{line_number}" + violations = diagnose(message, value) + if fixture_set["expect"] == "accept" or fixture_set.get("rejection") == "order": + assert not violations, f"{location}: {sorted(violations)}" + elif line_number >= reject_line: + assert violations == {fixture_set["reason"]}, ( + f"{location}: expected {fixture_set['reason']}, diagnosed {sorted(violations)}" + ) + if message != "VoiceSource": + if fixture_set["expect"] == "accept": + if fixture_set.get("unknownFields"): + assert value["protocol"]["major"] == protocol["major"], f"major mismatch at {location}" + else: + assert value["protocol"] == protocol, f"protocol mismatch at {location}" + current_session = value.get("sessionId") + session_id = session_id or current_session + assert current_session == session_id, f"fixture changed sessions at {location}" + ordering_field = { + "RuntimeEvent": "sequence", "SessionControl": "requestSequence", "AudioFrame": "sequence", + }.get(message) + if ordering_field and not violations: + current = int(value[ordering_field]) + ordered = previous is None or current > previous + if fixture_set.get("rejection") == "order" and line_number >= reject_line: + assert not ordered, f"{location}: expected ordering rejection" + else: + assert ordered, f"{location}: ordering key must increase" + if ordered: + previous = current + if message == "RuntimeEvent" and "transcript" in value: + assert "synthetic" in value["transcript"]["text"].lower(), f"non-synthetic text at {location}" + if message == "AudioFrame" and not violations: + validate_pcm_length(value, location) + for unknown_path in fixture_set.get("unknownFields", []): + assert any(get_path(value, unknown_path) is not None for value in values), ( + f"unknown path {unknown_path} is absent from {fixture_set['name']}" + ) + parts = unknown_path.split("/") + owner = message if len(parts) == 1 else parts[-2] + assert parts[-1] not in KNOWN_FIELDS.get(owner, set()), ( + f"{unknown_path} is a known field in {fixture_set['name']}" ) - sequence = int(event["sequence"]) - assert sequence > previous_sequence, f"sequence must increase at {path}:{line_number}" - previous_sequence = sequence - session_id = session_id or event["sessionId"] - assert event["sessionId"] == session_id, f"fixture changed sessions at {path}:{line_number}" - present = PAYLOAD_FIELDS.intersection(event) - assert len(present) == 1, f"expected one payload at {path}:{line_number}" - connector = json.loads((ROOT / "connectors/omi/connector.json").read_text(encoding="utf-8")) assert connector["protocolMajor"] == protocol["major"], "connector protocol major mismatch" - implementation = ROOT / connector["implementation"]["path"] - assert implementation.is_dir(), "connector implementation path does not exist" - - print("Murmur protocol fixtures and connector manifests are consistent.") + assert (ROOT / connector["implementation"]["path"]).is_dir(), "connector implementation path missing" + total_lines = sum(fixture_set["lines"] for fixture_set in fixture_sets) + print( + "Murmur protocol fixtures and connector manifests are consistent " + f"({len(fixture_sets)} sets, {total_lines} lines)." + ) if __name__ == "__main__":