1517 lines
60 KiB
Rust
1517 lines
60 KiB
Rust
//! Deterministic property-test generators for the public types of A
|
||
//! ([`epiphany_determinism`]), B ([`epiphany_core`]), C ([`epiphany_ops`]), and
|
||
//! D ([`epiphany_bundle`]). Agent E's layout-IR types
|
||
//! ([`epiphany_layout_ir`]) are generated in [`crate::layout_stub`], which
|
||
//! drives the real crate.
|
||
//!
|
||
//! Every generator draws from the seeded [`Rng`], so a failing case reproduces
|
||
//! from its seed (Appendix D §"Randomness": no platform entropy in the harness).
|
||
//! Whole **graphs** are generated by Agent B's own positive/negative generators,
|
||
//! re-exported as [`graph`].
|
||
|
||
use epiphany_bundle::{
|
||
chunk_id, content_hash_for, BlobId, BlobRef, ChunkKind, ChunkRef, CommitState,
|
||
CompressionAlgorithm, DocumentId, ExtensionDeclaration, ExtensionId, FrontierBytes, LineageId,
|
||
Manifest, ProfileConstraints, ProfileDeclaration, ProfileId, ProfileRegistryId,
|
||
ReductionAlgorithmVersion, RetentionPolicy, SchemaVersion, SemVer, SnapshotId, SnapshotRef,
|
||
Superblock, WallClockDuration as BundleWallClockDuration, WallClockTime as BundleWallClockTime,
|
||
};
|
||
use epiphany_core::{
|
||
AnalysisLayerId, AnalyticalAnnotationId, BarlineAlignmentGroupId, BeamId, ChordSymbolId,
|
||
CommentId, EventId, GraphicGestureId, GraphicObjectId, InstrumentId, IntegrityAnomalyId,
|
||
LyricLineId, MarkerId, MeasureId, MusicalDuration, MusicalPosition, ObjectKindRegistryId,
|
||
OperationId, PartDefinitionId, PitchId, RationalTime, RegionId, RepeatStructureId, ReplicaId,
|
||
SlurId, SpannerId, StaffGroupId, StaffId, StaffInstanceId, TieId, TimeSignatureId,
|
||
TransactionId, TupletId, TypedObjectId, ViewId, VoiceId, WallClockDuration, WallClockTime,
|
||
};
|
||
use epiphany_determinism::{
|
||
CanonicalEncode, CanonicalF64, ChunkId, ContentHash, DomainTag, QuantizedCoord, Tolerance,
|
||
ToleranceClass, ToleranceGovernance,
|
||
};
|
||
use epiphany_ops::valuegen;
|
||
use epiphany_ops::{
|
||
AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId,
|
||
ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry,
|
||
ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp,
|
||
DeleteIdentifiedPitchOp, ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp,
|
||
InsertIdentifiedPitchOp, IntegrityAnomaly, IntegrityAnomalyKind, IntegrityAnomalyRegistryId,
|
||
MaterializedState, ModifyEventOp, ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, ObjectState,
|
||
OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId, OperationPayload,
|
||
OperationSet, OperationStamp, PendingReason, PositionRemapping, PreconditionFailureReason,
|
||
PreconditionFailureRegistryId, ReanchorReason, ReanchorReasonRegistryId, ReanchorResult,
|
||
RepairKind, RepairKindRegistryId, RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId,
|
||
ResolutionAction, ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp,
|
||
SerializedCanonicalInputs, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor,
|
||
TransposeOp, TupletCompensation, TupletCompensationKind, UndoPolicy, UndoTransactionPayload,
|
||
};
|
||
|
||
use crate::rng::Rng;
|
||
|
||
/// Agent B's score-graph generators and shrinkers, re-exported so the testkit's
|
||
/// consumers have one import for *all* generators: `testkit::generators::graph`.
|
||
pub use epiphany_core::generators as graph;
|
||
|
||
// ===========================================================================
|
||
// Agent A — epiphany-determinism
|
||
// ===========================================================================
|
||
|
||
/// A canonical staff-space coordinate. Every `i64` unit count is valid.
|
||
pub fn quantized_coord(rng: &mut Rng) -> QuantizedCoord {
|
||
QuantizedCoord::from_units(rng.next_u64() as i64)
|
||
}
|
||
|
||
/// A finite canonical `f64`. Non-finite bit patterns (NaN/inf), a tiny fraction
|
||
/// of the space, are re-drawn; `-0.0` is canonicalized to `+0.0` by the type.
|
||
pub fn canonical_f64(rng: &mut Rng) -> CanonicalF64 {
|
||
for _ in 0..8 {
|
||
if let Some(c) = CanonicalF64::new(f64::from_bits(rng.next_u64())) {
|
||
return c;
|
||
}
|
||
}
|
||
CanonicalF64::new(0.0).unwrap()
|
||
}
|
||
|
||
/// A content hash (any 32 bytes are valid).
|
||
pub fn content_hash(rng: &mut Rng) -> ContentHash {
|
||
ContentHash(rng.array32())
|
||
}
|
||
|
||
/// A chunk id (newtype over [`ContentHash`]).
|
||
pub fn chunk_id_gen(rng: &mut Rng) -> ChunkId {
|
||
ChunkId(content_hash(rng))
|
||
}
|
||
|
||
/// A valid domain tag, drawn from the built-in `MUSC*` vocabulary.
|
||
pub fn domain_tag(rng: &mut Rng) -> DomainTag {
|
||
*rng.choose(&DomainTag::BUILTINS)
|
||
}
|
||
|
||
/// A typed tolerance (not a canonical-serialized type — generated for
|
||
/// completeness of the "every public type" charter, exercised by validation
|
||
/// tests rather than the byte round-trip).
|
||
pub fn tolerance(rng: &mut Rng) -> Tolerance {
|
||
let class = *rng.choose(&[
|
||
ToleranceClass::AcousticCents,
|
||
ToleranceClass::LayoutCoordinate,
|
||
ToleranceClass::QualityMetric,
|
||
ToleranceClass::TempoIntegration,
|
||
ToleranceClass::SolverResidual,
|
||
]);
|
||
let governance = *rng.choose(&[
|
||
ToleranceGovernance::Equality,
|
||
ToleranceGovernance::Validation,
|
||
ToleranceGovernance::Diagnostic,
|
||
]);
|
||
let absolute = (rng.range(1, 1_000_000) as f64) / 1_000_000.0;
|
||
Tolerance::absolute(class, absolute, governance).expect("finite positive tolerance")
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Agent B — epiphany-core: identifiers, time, typed-object id
|
||
// ===========================================================================
|
||
|
||
/// A non-reserved replica identifier (never [`ReplicaId::SYSTEM_DERIVED`]).
|
||
pub fn replica_id(rng: &mut Rng) -> ReplicaId {
|
||
ReplicaId::from_entropy(rng.next_u64().to_le_bytes()).unwrap_or(ReplicaId(0x5151_5151))
|
||
}
|
||
|
||
/// An operation identifier with a non-reserved replica.
|
||
pub fn operation_id(rng: &mut Rng) -> OperationId {
|
||
OperationId::new(replica_id(rng), rng.next_u64())
|
||
}
|
||
|
||
/// An opaque author identifier.
|
||
pub fn author_id(rng: &mut Rng) -> AuthorId {
|
||
AuthorId(((rng.next_u64() as u128) << 64) | rng.next_u64() as u128)
|
||
}
|
||
|
||
/// A content-addressed conflict identifier value.
|
||
pub fn conflict_id(rng: &mut Rng) -> ConflictId {
|
||
ConflictId(((rng.next_u64() as u128) << 64) | rng.next_u64() as u128)
|
||
}
|
||
|
||
macro_rules! id_generator {
|
||
($(#[$m:meta])* $fn_name:ident -> $ty:ident) => {
|
||
$(#[$m])*
|
||
pub fn $fn_name(rng: &mut Rng) -> $ty {
|
||
$ty::new(replica_id(rng), rng.next_u64())
|
||
}
|
||
};
|
||
}
|
||
|
||
id_generator!(/// An event identifier.
|
||
event_id -> EventId);
|
||
id_generator!(/// A pitch identifier.
|
||
pitch_id -> PitchId);
|
||
id_generator!(/// A voice identifier.
|
||
voice_id -> VoiceId);
|
||
id_generator!(/// A staff identifier.
|
||
staff_id -> StaffId);
|
||
id_generator!(/// A staff-instance identifier.
|
||
staff_instance_id -> StaffInstanceId);
|
||
id_generator!(/// A staff-group identifier.
|
||
staff_group_id -> StaffGroupId);
|
||
id_generator!(/// A region identifier.
|
||
region_id -> RegionId);
|
||
id_generator!(/// An instrument identifier.
|
||
instrument_id -> InstrumentId);
|
||
id_generator!(/// A part-definition identifier.
|
||
part_definition_id -> PartDefinitionId);
|
||
id_generator!(/// A measure identifier.
|
||
measure_id -> MeasureId);
|
||
id_generator!(/// A barline-alignment-group identifier.
|
||
barline_alignment_group_id -> BarlineAlignmentGroupId);
|
||
id_generator!(/// A tuplet identifier.
|
||
tuplet_id -> TupletId);
|
||
id_generator!(/// A slur identifier.
|
||
slur_id -> SlurId);
|
||
id_generator!(/// A tie identifier.
|
||
tie_id -> TieId);
|
||
id_generator!(/// A beam identifier.
|
||
beam_id -> BeamId);
|
||
id_generator!(/// A spanner identifier.
|
||
spanner_id -> SpannerId);
|
||
id_generator!(/// A marker identifier.
|
||
marker_id -> MarkerId);
|
||
id_generator!(/// An analytical-annotation identifier.
|
||
analytical_annotation_id -> AnalyticalAnnotationId);
|
||
id_generator!(/// A comment identifier.
|
||
comment_id -> CommentId);
|
||
id_generator!(/// A graphic-object identifier.
|
||
graphic_object_id -> GraphicObjectId);
|
||
id_generator!(/// A graphic-gesture identifier.
|
||
graphic_gesture_id -> GraphicGestureId);
|
||
id_generator!(/// A time-signature identifier.
|
||
time_signature_id -> TimeSignatureId);
|
||
id_generator!(/// An analysis-layer identifier.
|
||
analysis_layer_id -> AnalysisLayerId);
|
||
id_generator!(/// A repeat-structure identifier.
|
||
repeat_structure_id -> RepeatStructureId);
|
||
id_generator!(/// A lyric-line identifier.
|
||
lyric_line_id -> LyricLineId);
|
||
id_generator!(/// A chord-symbol identifier.
|
||
chord_symbol_id -> ChordSymbolId);
|
||
id_generator!(/// A view identifier.
|
||
view_id -> ViewId);
|
||
id_generator!(/// An object-kind registry identifier.
|
||
object_kind_registry_id -> ObjectKindRegistryId);
|
||
id_generator!(/// A transaction identifier.
|
||
transaction_id -> TransactionId);
|
||
id_generator!(/// An integrity-anomaly identifier.
|
||
integrity_anomaly_id -> IntegrityAnomalyId);
|
||
|
||
/// A small (inline) rational time value with a non-zero denominator.
|
||
pub fn rational_time_small(rng: &mut Rng) -> RationalTime {
|
||
loop {
|
||
let num = (rng.next_u64() % (1 << 20)) as i64 - (1 << 19);
|
||
let den = rng.range(1, 4096) as i64;
|
||
if let Some(r) = RationalTime::new(num, den) {
|
||
return r;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A promoted (arbitrary-precision `Large`) rational time value. The numerator
|
||
/// is forced far enough beyond the inline `i32` range that even after dividing
|
||
/// by the (small) denominator's gcd the reduced numerator still exceeds
|
||
/// `i32::MAX`, so the value cannot demote to the inline arm.
|
||
pub fn rational_time_large(rng: &mut Rng) -> RationalTime {
|
||
// `i32::MAX * 16` guarantees `num / den > i32::MAX` for every den <= 13.
|
||
let big = (i32::MAX as i64) * 16 + (rng.next_u64() % 1_000_000_000) as i64;
|
||
let den = *rng.choose(&[1i64, 3, 7, 11, 13]);
|
||
RationalTime::new(big, den).expect("non-zero denominator")
|
||
}
|
||
|
||
/// A rational time value, half inline and half promoted — so the round-trip
|
||
/// corpus exercises both [`RationalTime`] arms.
|
||
pub fn rational_time(rng: &mut Rng) -> RationalTime {
|
||
if rng.boolean() {
|
||
rational_time_small(rng)
|
||
} else {
|
||
rational_time_large(rng)
|
||
}
|
||
}
|
||
|
||
/// A musical position (rational, possibly promoted).
|
||
pub fn musical_position(rng: &mut Rng) -> MusicalPosition {
|
||
MusicalPosition(rational_time(rng))
|
||
}
|
||
|
||
/// A musical duration (rational, possibly promoted).
|
||
pub fn musical_duration(rng: &mut Rng) -> MusicalDuration {
|
||
MusicalDuration(rational_time(rng))
|
||
}
|
||
|
||
/// A wall-clock time (signed nanoseconds).
|
||
pub fn wallclock_time(rng: &mut Rng) -> WallClockTime {
|
||
WallClockTime(rng.next_u64() as i64)
|
||
}
|
||
|
||
/// A wall-clock duration (signed nanoseconds).
|
||
pub fn wallclock_duration(rng: &mut Rng) -> WallClockDuration {
|
||
WallClockDuration(rng.next_u64() as i64)
|
||
}
|
||
|
||
/// A tagged object identifier across the **whole** graph identity family — every
|
||
/// [`TypedObjectId`] variant including the variable-width `Registered` form, so
|
||
/// the round-trip harness exercises every discriminant and both encodings.
|
||
pub fn typed_object_id(rng: &mut Rng) -> TypedObjectId {
|
||
match rng.below(28) {
|
||
0 => TypedObjectId::Event(event_id(rng)),
|
||
1 => TypedObjectId::Pitch(pitch_id(rng)),
|
||
2 => TypedObjectId::Voice(voice_id(rng)),
|
||
3 => TypedObjectId::Staff(staff_id(rng)),
|
||
4 => TypedObjectId::StaffInstance(staff_instance_id(rng)),
|
||
5 => TypedObjectId::StaffGroup(staff_group_id(rng)),
|
||
6 => TypedObjectId::Region(region_id(rng)),
|
||
7 => TypedObjectId::Instrument(instrument_id(rng)),
|
||
8 => TypedObjectId::PartDefinition(part_definition_id(rng)),
|
||
9 => TypedObjectId::Measure(measure_id(rng)),
|
||
10 => TypedObjectId::BarlineAlignmentGroup(barline_alignment_group_id(rng)),
|
||
11 => TypedObjectId::Slur(slur_id(rng)),
|
||
12 => TypedObjectId::Tie(tie_id(rng)),
|
||
13 => TypedObjectId::Beam(beam_id(rng)),
|
||
14 => TypedObjectId::Spanner(spanner_id(rng)),
|
||
15 => TypedObjectId::Marker(marker_id(rng)),
|
||
16 => TypedObjectId::AnalyticalAnnotation(analytical_annotation_id(rng)),
|
||
17 => TypedObjectId::Comment(comment_id(rng)),
|
||
18 => TypedObjectId::GraphicObject(graphic_object_id(rng)),
|
||
19 => TypedObjectId::GraphicGesture(graphic_gesture_id(rng)),
|
||
20 => TypedObjectId::TimeSignature(time_signature_id(rng)),
|
||
21 => TypedObjectId::AnalysisLayer(analysis_layer_id(rng)),
|
||
22 => TypedObjectId::Tuplet(tuplet_id(rng)),
|
||
23 => TypedObjectId::RepeatStructure(repeat_structure_id(rng)),
|
||
24 => TypedObjectId::LyricLine(lyric_line_id(rng)),
|
||
25 => TypedObjectId::ChordSymbol(chord_symbol_id(rng)),
|
||
26 => TypedObjectId::View(view_id(rng)),
|
||
_ => TypedObjectId::Registered(
|
||
object_kind_registry_id(rng),
|
||
((rng.next_u64() as u128) << 64) | rng.next_u64() as u128,
|
||
),
|
||
}
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Agent C — epiphany-ops: real operation envelopes
|
||
// ===========================================================================
|
||
|
||
/// The shared object-identifier namespace authoring replicas mint *references*
|
||
/// into, so operations from different replicas address the *same* events,
|
||
/// pitches, and voices — which is what makes their reductions interact
|
||
/// (tombstones, already-applied, field conflicts) and what gives the convergence
|
||
/// harness something non-trivial to converge.
|
||
const OBJ_REPLICA: ReplicaId = ReplicaId(0x0B7E_C700);
|
||
|
||
fn obj_event(n: u64) -> EventId {
|
||
EventId::new(OBJ_REPLICA, n)
|
||
}
|
||
fn obj_pitch(n: u64) -> PitchId {
|
||
PitchId::new(OBJ_REPLICA, n)
|
||
}
|
||
|
||
/// A conflict-resolution action (every core and registered variant,
|
||
/// including `Dismiss`).
|
||
pub fn resolution_action(rng: &mut Rng) -> ResolutionAction {
|
||
match rng.below(6) {
|
||
0 => ResolutionAction::AcceptLoser,
|
||
1 => ResolutionAction::KeepWinner,
|
||
2 => ResolutionAction::Override {
|
||
override_operation: operation_id(rng),
|
||
},
|
||
3 => ResolutionAction::Reanchor {
|
||
new_target: typed_object_id(rng),
|
||
},
|
||
4 => ResolutionAction::Dismiss,
|
||
_ => ResolutionAction::Registered(ResolutionRegistryId(
|
||
((rng.next_u64() as u128) << 64) | rng.next_u64() as u128,
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// An undo policy (`StrictInverse`/`BestEffort`/`Cascade`).
|
||
pub fn undo_policy(rng: &mut Rng) -> UndoPolicy {
|
||
*rng.choose(&[
|
||
UndoPolicy::StrictInverse,
|
||
UndoPolicy::BestEffort,
|
||
UndoPolicy::Cascade,
|
||
])
|
||
}
|
||
|
||
/// A conflict kind covering every core and extension variant.
|
||
pub fn conflict_kind(rng: &mut Rng) -> ConflictKind {
|
||
match rng.below(6) {
|
||
0 => ConflictKind::StructuralFieldCollision {
|
||
winner: operation_id(rng),
|
||
loser: operation_id(rng),
|
||
field: FieldPath(String::from("event.pitch")),
|
||
},
|
||
1 => ConflictKind::TransactionConflict {
|
||
transaction: transaction_id(rng),
|
||
failed_members: vec![operation_id(rng), operation_id(rng)],
|
||
},
|
||
2 => ConflictKind::TombstonedTarget {
|
||
target: typed_object_id(rng),
|
||
operation: operation_id(rng),
|
||
},
|
||
3 => ConflictKind::ReanchorFailure {
|
||
original_referent: typed_object_id(rng),
|
||
referencing_object: typed_object_id(rng),
|
||
},
|
||
4 => ConflictKind::TimeModelMigrationFailure {
|
||
region: region_id(rng),
|
||
incompatible_events: vec![typed_object_id(rng), typed_object_id(rng)],
|
||
},
|
||
_ => ConflictKind::ExtensionConflict {
|
||
kind_id: ConflictKindRegistryId(
|
||
((rng.next_u64() as u128) << 64) | rng.next_u64() as u128,
|
||
),
|
||
details: rng.byte_vec(0, 24),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// A conflict resolution state covering every variant.
|
||
pub fn conflict_resolution_state(rng: &mut Rng) -> ConflictResolutionState {
|
||
match rng.below(3) {
|
||
0 => ConflictResolutionState::Unresolved,
|
||
1 => ConflictResolutionState::Resolved {
|
||
by: operation_id(rng),
|
||
action: resolution_action(rng),
|
||
},
|
||
_ => ConflictResolutionState::Dismissed {
|
||
by: operation_id(rng),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// A self-consistent content-addressed conflict record.
|
||
pub fn conflict_record(rng: &mut Rng) -> ConflictRecord {
|
||
let mut record = ConflictRecord::new(
|
||
conflict_kind(rng),
|
||
vec![operation_id(rng), operation_id(rng)],
|
||
vec![typed_object_id(rng), typed_object_id(rng)],
|
||
);
|
||
record.resolution_state = conflict_resolution_state(rng);
|
||
record
|
||
}
|
||
|
||
/// A canonical conflict registry containing a few generated records.
|
||
pub fn conflict_registry(rng: &mut Rng) -> ConflictRegistry {
|
||
let mut registry = ConflictRegistry::new();
|
||
for _ in 0..rng.range_usize(0, 4) {
|
||
registry.insert(conflict_record(rng));
|
||
}
|
||
registry
|
||
}
|
||
|
||
/// A typed precondition failure (every core and registered variant).
|
||
pub fn precondition_failure_reason(rng: &mut Rng) -> PreconditionFailureReason {
|
||
match rng.below(10) {
|
||
0 => PreconditionFailureReason::TargetMissing,
|
||
1 => PreconditionFailureReason::TargetTombstoned,
|
||
2 => PreconditionFailureReason::WrongRegionTimeModel,
|
||
3 => PreconditionFailureReason::TupletCompensationInvalid,
|
||
4 => PreconditionFailureReason::EventDurationInvalid,
|
||
5 => PreconditionFailureReason::PositionOutsideRegion,
|
||
6 => PreconditionFailureReason::PitchSpaceMismatch,
|
||
7 => PreconditionFailureReason::VoiceMissing,
|
||
8 => PreconditionFailureReason::ExtensionPrecondition(ExtensionPreconditionId(
|
||
rng.next_u64() as u128,
|
||
)),
|
||
_ => PreconditionFailureReason::Registered(PreconditionFailureRegistryId(
|
||
rng.next_u64() as u128
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// A no-op reason covering every variant.
|
||
pub fn no_op_reason(rng: &mut Rng) -> NoOpReason {
|
||
match rng.below(5) {
|
||
0 => NoOpReason::TargetTombstoned,
|
||
1 => NoOpReason::AlreadyApplied,
|
||
2 => NoOpReason::SupersededByLaterOperation {
|
||
superseder: operation_id(rng),
|
||
},
|
||
3 => NoOpReason::PreconditionFailedUnderReduction {
|
||
reason: precondition_failure_reason(rng),
|
||
},
|
||
_ => NoOpReason::TransactionConflict,
|
||
}
|
||
}
|
||
|
||
/// A re-anchor reason covering every variant.
|
||
pub fn reanchor_reason(rng: &mut Rng) -> ReanchorReason {
|
||
match rng.below(6) {
|
||
0 => ReanchorReason::SameVoiceNearer,
|
||
1 => ReanchorReason::SameStaffInstanceNearer,
|
||
2 => ReanchorReason::SameStaffNearer,
|
||
3 => ReanchorReason::SameRegionNearer,
|
||
4 => ReanchorReason::ExplicitFallback,
|
||
_ => ReanchorReason::DeclaredByExtension(ReanchorReasonRegistryId(rng.next_u64() as u128)),
|
||
}
|
||
}
|
||
|
||
/// A tuplet compensation result covering every variant.
|
||
pub fn tuplet_compensation_kind(rng: &mut Rng) -> TupletCompensationKind {
|
||
*rng.choose(&[
|
||
TupletCompensationKind::ReplaceWithRest,
|
||
TupletCompensationKind::RewriteTuplets,
|
||
TupletCompensationKind::CascadeDeleteTuplets,
|
||
])
|
||
}
|
||
|
||
/// A repair kind covering every core and registered variant.
|
||
pub fn repair_kind(rng: &mut Rng) -> RepairKind {
|
||
match rng.below(8) {
|
||
0 => RepairKind::Reanchored {
|
||
from: typed_object_id(rng),
|
||
to: typed_object_id(rng),
|
||
reason: reanchor_reason(rng),
|
||
},
|
||
1 => RepairKind::SpannerTruncated {
|
||
removed_members: vec![typed_object_id(rng), typed_object_id(rng)],
|
||
},
|
||
2 => RepairKind::Orphaned,
|
||
3 => RepairKind::CascadeDeleted,
|
||
4 => RepairKind::AttachmentTombstoned,
|
||
5 => RepairKind::VoicePromoted {
|
||
from: voice_id(rng),
|
||
to: voice_id(rng),
|
||
},
|
||
6 => RepairKind::TupletCompensated {
|
||
compensation_kind: tuplet_compensation_kind(rng),
|
||
},
|
||
_ => RepairKind::Registered(RepairKindRegistryId(rng.next_u64() as u128)),
|
||
}
|
||
}
|
||
|
||
/// A complete repair record.
|
||
pub fn repair_record(rng: &mut Rng) -> RepairRecord {
|
||
RepairRecord {
|
||
kind: repair_kind(rng),
|
||
target: typed_object_id(rng),
|
||
}
|
||
}
|
||
|
||
/// An operation effect covering every variant.
|
||
pub fn operation_effect(rng: &mut Rng) -> OperationEffect {
|
||
match rng.below(5) {
|
||
0 => OperationEffect::Applied,
|
||
1 => OperationEffect::AppliedWithRepair {
|
||
repairs: vec![repair_record(rng)],
|
||
},
|
||
2 => OperationEffect::Conflicted {
|
||
conflict: ConflictId(rng.next_u64() as u128),
|
||
},
|
||
3 => OperationEffect::TombstonedTarget {
|
||
target: typed_object_id(rng),
|
||
},
|
||
_ => OperationEffect::NoOp {
|
||
reason: no_op_reason(rng),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// The result of a re-anchor attempt (every variant).
|
||
pub fn reanchor_result(rng: &mut Rng) -> ReanchorResult {
|
||
match rng.below(5) {
|
||
0 => ReanchorResult::Reanchored {
|
||
new_target: typed_object_id(rng),
|
||
reason: reanchor_reason(rng),
|
||
},
|
||
1 => ReanchorResult::TombstonedTarget,
|
||
2 => ReanchorResult::Orphaned,
|
||
3 => ReanchorResult::Conflicted {
|
||
conflict: ConflictId(rng.next_u64() as u128),
|
||
},
|
||
_ => ReanchorResult::CascadeDeleted,
|
||
}
|
||
}
|
||
|
||
/// An integrity anomaly kind covering every core and registered variant.
|
||
pub fn integrity_anomaly_kind(rng: &mut Rng) -> IntegrityAnomalyKind {
|
||
match rng.below(4) {
|
||
0 => IntegrityAnomalyKind::SystemIdentifierCollision {
|
||
kind: match rng.below(3) {
|
||
0 => ObjectKind::Voice,
|
||
1 => ObjectKind::Pitch,
|
||
_ => ObjectKind::Registered(OperationKindRegistryId(rng.next_u64() as u128)),
|
||
},
|
||
colliding_counter: rng.next_u64(),
|
||
input_set_a: SerializedCanonicalInputs(rng.byte_vec(0, 16)),
|
||
input_set_b: SerializedCanonicalInputs(rng.byte_vec(0, 16)),
|
||
},
|
||
1 => IntegrityAnomalyKind::OperationSlotEquivocated {
|
||
operation_id: operation_id(rng),
|
||
},
|
||
2 => IntegrityAnomalyKind::ReplicaStreamQuarantined {
|
||
replica: replica_id(rng),
|
||
first_bad_counter: rng.next_u64(),
|
||
},
|
||
_ => IntegrityAnomalyKind::Registered(IntegrityAnomalyRegistryId(rng.next_u64() as u128)),
|
||
}
|
||
}
|
||
|
||
/// A content-derived integrity anomaly.
|
||
pub fn integrity_anomaly(rng: &mut Rng) -> IntegrityAnomaly {
|
||
IntegrityAnomaly::new(integrity_anomaly_kind(rng))
|
||
}
|
||
|
||
/// A per-replica anomaly reason covering core and registered forms.
|
||
pub fn replica_anomaly_reason(rng: &mut Rng) -> ReplicaAnomalyReason {
|
||
if rng.boolean() {
|
||
ReplicaAnomalyReason::HlcMonotonicityViolation {
|
||
violating_pair: (operation_id(rng), operation_id(rng)),
|
||
}
|
||
} else {
|
||
ReplicaAnomalyReason::Registered(ReplicaAnomalyRegistryId(rng.next_u64() as u128))
|
||
}
|
||
}
|
||
|
||
/// An anomalous replica segment with a sorted excluded-id list.
|
||
pub fn anomalous_replica_segment(rng: &mut Rng) -> AnomalousReplicaSegment {
|
||
let replica = replica_id(rng);
|
||
let first_bad_counter = rng.range(0, 32);
|
||
AnomalousReplicaSegment {
|
||
replica,
|
||
first_bad_counter,
|
||
reason: replica_anomaly_reason(rng),
|
||
excluded: (first_bad_counter..first_bad_counter + rng.range(1, 4))
|
||
.map(|counter| OperationId::new(replica, counter))
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
/// An object-existence state covering live and tombstoned forms.
|
||
pub fn object_state(rng: &mut Rng) -> ObjectState {
|
||
if rng.boolean() {
|
||
ObjectState::Live
|
||
} else {
|
||
ObjectState::Tombstoned {
|
||
deleted_by: operation_id(rng),
|
||
minted_by: operation_id(rng),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A pending reason covering every blocker category.
|
||
pub fn pending_reason(rng: &mut Rng) -> PendingReason {
|
||
let blocker = operation_id(rng);
|
||
match rng.below(4) {
|
||
0 => PendingReason::MissingCausalPredecessor { missing: blocker },
|
||
1 => PendingReason::DependsOnEquivocated { on: blocker },
|
||
2 => PendingReason::DependsOnExcluded { on: blocker },
|
||
_ => PendingReason::DependsOnPending { on: blocker },
|
||
}
|
||
}
|
||
|
||
/// A random representative operation payload over a shared id space of `events`
|
||
/// events and `pitches` pitches. Covers the primitive reduction disciplines
|
||
/// (Chapter 6 §6.10: insert, delete, respell, cross-cutting create,
|
||
/// layout-semantic break, time-model change, transaction declaration,
|
||
/// registered extension op) and the two meta-operations (resolve-conflict and
|
||
/// undo-transaction).
|
||
///
|
||
/// Zero-sized id spaces are treated as size 1 (the shared object namespace
|
||
/// always has at least one addressable id), so this never calls
|
||
/// [`Rng::below`](crate::rng::Rng::below) with a zero bound.
|
||
pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationPayload {
|
||
let events = events.max(1);
|
||
let pitches = pitches.max(1);
|
||
// ~1 in 6 operations is a meta-operation (resolve-conflict / undo).
|
||
match rng.below(12) {
|
||
10 => {
|
||
return OperationPayload::ResolveConflict(ResolveConflictPayload {
|
||
target: ConflictId(((rng.next_u64() as u128) << 64) | rng.next_u64() as u128),
|
||
action: resolution_action(rng),
|
||
})
|
||
}
|
||
11 => {
|
||
return OperationPayload::UndoTransaction(UndoTransactionPayload {
|
||
target: TransactionId::new(OBJ_REPLICA, rng.below(events)),
|
||
policy: undo_policy(rng),
|
||
})
|
||
}
|
||
_ => {}
|
||
}
|
||
let kind = match rng.below(13) {
|
||
0 => {
|
||
let pitches = if rng.boolean() {
|
||
vec![obj_pitch(rng.below(pitches))]
|
||
} else {
|
||
vec![]
|
||
};
|
||
OperationKind::InsertEvent(InsertEventOp {
|
||
staff_instance: StaffInstanceId::new(OBJ_REPLICA, rng.below(2)),
|
||
event: valuegen::insert_event_value(
|
||
obj_event(rng.below(events)),
|
||
VoiceId::new(OBJ_REPLICA, rng.below(4)),
|
||
MusicalPosition(RationalTime::from_int(rng.below(events) as i32)),
|
||
MusicalDuration::whole(),
|
||
&pitches,
|
||
),
|
||
})
|
||
}
|
||
1 => OperationKind::DeleteEvent(DeleteEventOp {
|
||
event: obj_event(rng.below(events)),
|
||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||
}),
|
||
2 => OperationKind::RespellPitch(RespellPitchOp {
|
||
pitch: obj_pitch(rng.below(pitches)),
|
||
spelling: valuegen::spelling(rng.below(4) as u8 + 1),
|
||
}),
|
||
3 => OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
||
structure: CrossCuttingValue::Slur(valuegen::slur(
|
||
SlurId::new(OBJ_REPLICA, rng.below(events)),
|
||
obj_event(rng.below(events)),
|
||
obj_event(rng.below(events)),
|
||
)),
|
||
}),
|
||
4 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
||
region: RegionId::new(OBJ_REPLICA, 0),
|
||
anchor: valuegen::region_start_anchor(
|
||
RegionId::new(OBJ_REPLICA, 0),
|
||
MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
|
||
),
|
||
present: rng.boolean(),
|
||
}),
|
||
5 => OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp {
|
||
region: RegionId::new(OBJ_REPLICA, rng.below(2)),
|
||
new_time_model: match rng.below(3) {
|
||
0 => valuegen::metric_model(),
|
||
1 => valuegen::proportional_model(),
|
||
_ => valuegen::aleatoric_model(),
|
||
},
|
||
declared_incompatible: Vec::new(),
|
||
remapping: PositionRemapping::PreserveTime,
|
||
}),
|
||
6 => OperationKind::DeclareTransaction(TransactionDescriptor {
|
||
id: TransactionId::new(OBJ_REPLICA, rng.below(events)),
|
||
label: String::from("edit"),
|
||
category: Some(*rng.choose(&[
|
||
TransactionCategory::NoteEntry,
|
||
TransactionCategory::Structural,
|
||
TransactionCategory::Layout,
|
||
])),
|
||
}),
|
||
// Group 1 (M2): event & pitch leaf-field ops over the shared id space.
|
||
7 => OperationKind::ModifyEvent(ModifyEventOp {
|
||
event: valuegen::insert_event_value(
|
||
obj_event(rng.below(events)),
|
||
VoiceId::new(OBJ_REPLICA, rng.below(4)),
|
||
MusicalPosition(RationalTime::from_int(rng.below(events) as i32)),
|
||
MusicalDuration::whole(),
|
||
&[obj_pitch(rng.below(pitches))],
|
||
),
|
||
}),
|
||
8 => OperationKind::Transpose(TransposeOp {
|
||
targets: vec![obj_pitch(rng.below(pitches))],
|
||
chromatic_steps: rng.below(5) as i32 - 2,
|
||
}),
|
||
9 => OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp {
|
||
event: obj_event(rng.below(events)),
|
||
pitch: valuegen::identified_pitch(obj_pitch(rng.below(pitches))),
|
||
}),
|
||
10 => OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp {
|
||
pitch: obj_pitch(rng.below(pitches)),
|
||
}),
|
||
11 => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
|
||
pitch: obj_pitch(rng.below(pitches)),
|
||
value: valuegen::pitch_value_nth(rng.below(4) as u8 + 1),
|
||
}),
|
||
_ => OperationKind::Registered(
|
||
OperationKindRegistryId(rng.next_u64() as u128),
|
||
rng.byte_vec(0, 16),
|
||
),
|
||
};
|
||
OperationPayload::Primitive(kind)
|
||
}
|
||
|
||
/// A well-formed set of real [`OperationEnvelope`]s authored by `n_replicas`
|
||
/// replicas over a shared id space, **honoring the HLC authoring contract**
|
||
/// (spec §"Identifiers", the `OperationStamp` requirement): every operation's
|
||
/// stamp is strictly greater than the stamp of every operation in its causal
|
||
/// context. This is achieved with the standard hybrid-logical-clock send rule —
|
||
/// a new stamp is `max(local clock reading, this replica's previous stamp, the
|
||
/// maximum stamp over the declared causal predecessors)`, with the logical
|
||
/// counter bumped on ties — so the canonical reduction order (which sorts by the
|
||
/// HLC tuple) genuinely places causal predecessors before successors.
|
||
///
|
||
/// The DVV causal context references only **prior** operations: the author
|
||
/// always sees its own contiguous history, plus a random prefix of each other
|
||
/// replica's history. Concurrency across replicas is what makes delivery order
|
||
/// differ from the canonical reduction order.
|
||
///
|
||
/// [`assert_causal_order_respected`](crate::convergence::assert_causal_order_respected)
|
||
/// verifies the resulting histories actually satisfy the authoring contract.
|
||
/// A reusable authoring context: each [`Session::author`] call mints one
|
||
/// well-formed, causally-conformant envelope on a chosen replica via the HLC
|
||
/// send rule + prior-history DVV described on [`operation_envelopes`].
|
||
struct Session {
|
||
counters: Vec<u64>,
|
||
clocks: Vec<i64>,
|
||
/// Per-replica stamp history `(physical, logical)`, indexed by counter.
|
||
/// Stamps are monotonic within a replica, so `stamps[rr][k]` is the max over
|
||
/// `0..=k`.
|
||
stamps: Vec<Vec<(i64, u32)>>,
|
||
out: Vec<OperationEnvelope>,
|
||
}
|
||
|
||
impl Session {
|
||
fn new(n_replicas: usize) -> Self {
|
||
let n = n_replicas.max(1);
|
||
Session {
|
||
counters: vec![0; n],
|
||
clocks: vec![0; n],
|
||
stamps: vec![Vec::new(); n],
|
||
out: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn n_replicas(&self) -> usize {
|
||
self.counters.len()
|
||
}
|
||
|
||
/// Authors one operation on replica `r` carrying `payload`, honoring the HLC
|
||
/// authoring contract (the new stamp strictly outranks every causal
|
||
/// predecessor) with a prior-history DVV (own history always seen; others
|
||
/// sampled).
|
||
fn author(&mut self, rng: &mut Rng, r: usize, payload: OperationPayload) {
|
||
let n_replicas = self.counters.len();
|
||
let replica = ReplicaId(r as u64 + 1);
|
||
let c = self.counters[r];
|
||
let id = OperationId::new(replica, c);
|
||
|
||
let mut ctx = CausalContext::new();
|
||
let mut pred_max = (0i64, 0u32);
|
||
if c > 0 {
|
||
ctx = ctx.with_seen(replica, c - 1);
|
||
pred_max = pred_max.max(self.stamps[r][(c - 1) as usize]);
|
||
}
|
||
for rr in 0..n_replicas {
|
||
if rr == r {
|
||
continue;
|
||
}
|
||
let known = self.counters[rr];
|
||
if known > 0 && rng.boolean() {
|
||
let k = rng.below(known);
|
||
ctx = ctx.with_seen(ReplicaId(rr as u64 + 1), k);
|
||
pred_max = pred_max.max(self.stamps[rr][k as usize]);
|
||
}
|
||
}
|
||
|
||
self.clocks[r] += rng.below(3) as i64;
|
||
let pt = self.clocks[r];
|
||
let prev = self.stamps[r].last().copied().unwrap_or((0, 0));
|
||
let l = pt.max(prev.0).max(pred_max.0);
|
||
let logical = if l == prev.0 && l == pred_max.0 {
|
||
prev.1.max(pred_max.1) + 1
|
||
} else if l == prev.0 {
|
||
prev.1 + 1
|
||
} else if l == pred_max.0 {
|
||
pred_max.1 + 1
|
||
} else {
|
||
0
|
||
};
|
||
self.stamps[r].push((l, logical));
|
||
self.counters[r] += 1;
|
||
|
||
let env = OperationEnvelope {
|
||
id,
|
||
author: AuthorId(replica.0 as u128),
|
||
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(l), logical), id),
|
||
causal_context: ctx,
|
||
transaction: None,
|
||
payload,
|
||
};
|
||
debug_assert!(epiphany_ops::well_formed(&env).is_ok());
|
||
self.out.push(env);
|
||
}
|
||
}
|
||
|
||
/// A well-formed set of real [`OperationEnvelope`]s authored by `n_replicas`
|
||
/// replicas over a shared id space, **honoring the HLC authoring contract**
|
||
/// (spec §"Identifiers", the `OperationStamp` requirement): every operation's
|
||
/// stamp is strictly greater than the stamp of every operation in its causal
|
||
/// context. This is achieved with the standard hybrid-logical-clock send rule —
|
||
/// a new stamp is `max(local clock reading, this replica's previous stamp, the
|
||
/// maximum stamp over the declared causal predecessors)`, with the logical
|
||
/// counter bumped on ties — so the canonical reduction order (which sorts by the
|
||
/// HLC tuple) genuinely places causal predecessors before successors.
|
||
///
|
||
/// The DVV causal context references only **prior** operations: the author
|
||
/// always sees its own contiguous history, plus a random prefix of each other
|
||
/// replica's history. Concurrency across replicas is what makes delivery order
|
||
/// differ from the canonical reduction order.
|
||
///
|
||
/// [`assert_causal_order_respected`](crate::convergence::assert_causal_order_respected)
|
||
/// verifies the resulting histories actually satisfy the authoring contract.
|
||
pub fn operation_envelopes(
|
||
rng: &mut Rng,
|
||
n_ops: usize,
|
||
n_replicas: usize,
|
||
events: u64,
|
||
pitches: u64,
|
||
) -> Vec<OperationEnvelope> {
|
||
let mut session = Session::new(n_replicas);
|
||
for _ in 0..n_ops {
|
||
let r = rng.below(session.n_replicas() as u64) as usize;
|
||
let payload = operation_payload(rng, events, pitches);
|
||
session.author(rng, r, payload);
|
||
}
|
||
session.out
|
||
}
|
||
|
||
/// A real materialized state produced by reducing a generated conformant
|
||
/// operation set. This is preferable to assembling reducer-owned invariants by
|
||
/// hand for ordinary property tests.
|
||
pub fn materialized_state(rng: &mut Rng, n_ops: usize) -> MaterializedState {
|
||
let envelopes = operation_envelopes(rng, n_ops, 3, 8, 8);
|
||
let mut set = OperationSet::new();
|
||
set.accept_all(envelopes);
|
||
set.reduce()
|
||
}
|
||
|
||
/// Bars per staff in [`two_staff_edit_session`].
|
||
pub const TWO_STAFF_BARS: u64 = 50;
|
||
/// Half-note events per 4/4 bar (so the session spans exactly [`TWO_STAFF_BARS`]).
|
||
const EVENTS_PER_BAR: u64 = 2;
|
||
/// Per-staff event count in [`two_staff_edit_session`]: [`TWO_STAFF_BARS`] bars ×
|
||
/// two half-notes per 4/4 bar.
|
||
pub const TWO_STAFF_EVENTS_PER_STAFF: u64 = TWO_STAFF_BARS * EVENTS_PER_BAR;
|
||
|
||
/// An `InsertEvent` payload minting `event` (with `pitch`) into the given
|
||
/// staff-instance / voice as the `index`-th half-note (position `index/2`
|
||
/// whole-notes, i.e. two per 4/4 bar).
|
||
fn insert_at(instance: u64, voice: u64, event: u64, pitch: u64, index: u64) -> OperationPayload {
|
||
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
|
||
staff_instance: StaffInstanceId::new(OBJ_REPLICA, instance),
|
||
event: valuegen::insert_event_value(
|
||
obj_event(event),
|
||
VoiceId::new(OBJ_REPLICA, voice),
|
||
MusicalPosition(RationalTime::new(index as i64, EVENTS_PER_BAR as i64).unwrap()),
|
||
MusicalDuration(RationalTime::new(1, EVENTS_PER_BAR as i64).unwrap()),
|
||
&[obj_pitch(pitch)],
|
||
),
|
||
}))
|
||
}
|
||
|
||
/// The v0 criterion-1 scenario, **instantiated** (not merely modeled): a
|
||
/// two-staff score built by two replicas. Each staff (instance 0 / voice 0 and
|
||
/// instance 1 / voice 1) is filled with [`TWO_STAFF_EVENTS_PER_STAFF`] events
|
||
/// spanning ~50 bars, authored by *both* replicas (alternating), so both staves
|
||
/// are genuinely populated. A batch of overlapping deletes and respellings over
|
||
/// the shared id space then makes reduction order — not delivery order — decide
|
||
/// the materialized result. [`assert_two_staff_populated`] checks both staves
|
||
/// actually survive reduction.
|
||
pub fn two_staff_edit_session(rng: &mut Rng) -> Vec<OperationEnvelope> {
|
||
let n = TWO_STAFF_EVENTS_PER_STAFF;
|
||
let mut session = Session::new(2);
|
||
for i in 0..n {
|
||
session.author(rng, (i % 2) as usize, insert_at(0, 0, i, i, i));
|
||
session.author(
|
||
rng,
|
||
((i + 1) % 2) as usize,
|
||
insert_at(1, 1, n + i, n + i, i),
|
||
);
|
||
}
|
||
let total = 2 * n;
|
||
for _ in 0..80 {
|
||
let r = rng.below(2) as usize;
|
||
let payload = if rng.boolean() {
|
||
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
|
||
event: obj_event(rng.below(total)),
|
||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||
}))
|
||
} else {
|
||
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||
pitch: obj_pitch(rng.below(total)),
|
||
spelling: valuegen::spelling(rng.below(4) as u8 + 1),
|
||
}))
|
||
};
|
||
session.author(rng, r, payload);
|
||
}
|
||
session.out
|
||
}
|
||
|
||
/// Asserts both staves of a [`two_staff_edit_session`] are actually populated:
|
||
/// each staff's voice is live and at least one of its events survives reduction.
|
||
/// (The reducer materializes voices/events but not staff-instance objects, so
|
||
/// liveness is checked at the voice and event level.)
|
||
pub fn assert_two_staff_populated(envelopes: &[OperationEnvelope]) {
|
||
let mut set = OperationSet::new();
|
||
set.accept_all(envelopes.iter().cloned());
|
||
let state = set.reduce();
|
||
let n = TWO_STAFF_EVENTS_PER_STAFF;
|
||
|
||
let voice_live = |v: u64| {
|
||
matches!(
|
||
state
|
||
.objects
|
||
.get(&TypedObjectId::Voice(VoiceId::new(OBJ_REPLICA, v))),
|
||
Some(ObjectState::Live)
|
||
)
|
||
};
|
||
assert!(voice_live(0), "staff 0 voice is not live after reduction");
|
||
assert!(voice_live(1), "staff 1 voice is not live after reduction");
|
||
|
||
let live_events = |lo: u64, hi: u64| {
|
||
(lo..hi)
|
||
.filter(|e| {
|
||
matches!(
|
||
state.objects.get(&TypedObjectId::Event(obj_event(*e))),
|
||
Some(ObjectState::Live)
|
||
)
|
||
})
|
||
.count()
|
||
};
|
||
assert!(live_events(0, n) > 0, "staff 0 has no live events");
|
||
assert!(live_events(n, 2 * n) > 0, "staff 1 has no live events");
|
||
}
|
||
|
||
/// Whole-note position past which [`graph_edit_session`] inserts, chosen to
|
||
/// clear `valid_score`'s base content (quarter-note events in `[0, 1)`).
|
||
const GRAPH_SESSION_OFFSET: i64 = 4;
|
||
|
||
/// An `InsertEvent` payload targeting a **real** base voice (`staff_instance`
|
||
/// must be the voice's actual container), minting `event`/`pitch` under
|
||
/// [`OBJ_REPLICA`] as the `index`-th half-note past the base content. Unlike
|
||
/// [`insert_at`], this addresses ids that already exist in a base [`graph`]
|
||
/// score, so the payload survives [`OperationSet::reduce_onto`] (which rejects
|
||
/// inserts into unknown voices) rather than only the base-free reducer.
|
||
fn insert_into(
|
||
staff_instance: StaffInstanceId,
|
||
voice: VoiceId,
|
||
event: u64,
|
||
pitch: u64,
|
||
index: u64,
|
||
) -> OperationPayload {
|
||
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
|
||
staff_instance,
|
||
// position = GRAPH_SESSION_OFFSET + index/2 (two half-notes per 4/4 bar).
|
||
event: valuegen::insert_event_value(
|
||
obj_event(event),
|
||
voice,
|
||
MusicalPosition(
|
||
RationalTime::new(
|
||
GRAPH_SESSION_OFFSET * EVENTS_PER_BAR as i64 + index as i64,
|
||
2,
|
||
)
|
||
.unwrap(),
|
||
),
|
||
MusicalDuration(RationalTime::new(1, 2).unwrap()),
|
||
&[obj_pitch(pitch)],
|
||
),
|
||
}))
|
||
}
|
||
|
||
/// The graph-level twin of [`two_staff_edit_session`]: a real ~50-bar edit
|
||
/// session targeting the **actual** voices of `base`, for the
|
||
/// [`OperationSet::reduce_onto`] convergence gate (acceptance criterion 1).
|
||
///
|
||
/// Two replicas alternately insert [`TWO_STAFF_EVENTS_PER_STAFF`] half-note
|
||
/// events past the base content into the base's first two voices (so both
|
||
/// staves are genuinely edited), then concurrently respell and delete over the
|
||
/// shared minted id space, so the canonical reduction — not delivery order —
|
||
/// decides the materialized `Score`. Returns the targeted voices alongside the
|
||
/// envelopes (the convergence harness checks each one actually grew).
|
||
///
|
||
/// Requires a base with at least one voice; callers pass a base scanned for two
|
||
/// (see `epiphany_core::generators::valid_score`).
|
||
pub fn graph_edit_session(
|
||
base: &epiphany_core::Score,
|
||
rng: &mut Rng,
|
||
) -> (Vec<(StaffInstanceId, VoiceId)>, Vec<OperationEnvelope>) {
|
||
let targets: Vec<(StaffInstanceId, VoiceId)> = base
|
||
.voices()
|
||
.map(|(_, instance, voice)| (instance, voice.id))
|
||
.take(2)
|
||
.collect();
|
||
assert!(
|
||
!targets.is_empty(),
|
||
"graph_edit_session requires a base score with at least one voice"
|
||
);
|
||
|
||
let n = TWO_STAFF_EVENTS_PER_STAFF;
|
||
let mut session = Session::new(2);
|
||
for i in 0..n {
|
||
for (ti, &(instance, voice)) in targets.iter().enumerate() {
|
||
// Each target voice's consecutive half-notes alternate authoring
|
||
// replicas; the two voices are edited by opposite replicas at any
|
||
// given index. Positions are distinct within a voice (no
|
||
// same-position collision → clean inserts), while cross-replica
|
||
// causal sampling makes delivery order differ from reduction order.
|
||
let r = (i as usize + ti) % 2;
|
||
let object = ti as u64 * n + i;
|
||
session.author(rng, r, insert_into(instance, voice, object, object, i));
|
||
}
|
||
}
|
||
|
||
let total = targets.len() as u64 * n;
|
||
for _ in 0..80 {
|
||
let r = rng.below(2) as usize;
|
||
let payload = if rng.boolean() {
|
||
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
|
||
event: obj_event(rng.below(total)),
|
||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||
}))
|
||
} else {
|
||
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||
pitch: obj_pitch(rng.below(total)),
|
||
spelling: valuegen::spelling(rng.below(4) as u8 + 1),
|
||
}))
|
||
};
|
||
session.author(rng, r, payload);
|
||
}
|
||
(targets, session.out)
|
||
}
|
||
|
||
/// A `(base, mutated)` pair of operation sets whose operations have **identical
|
||
/// identities, stamps, and causal contexts** but differ in payload *content*
|
||
/// (one respelling's spelling). Both sets insert a pitch and respell it, so the
|
||
/// respelling takes effect; reducing them therefore yields *different* canonical
|
||
/// bytes — the exact rebuttal to an id-only "serializer" that would collapse
|
||
/// distinct scores. `mutated` is `base.clone()` with only one payload changed,
|
||
/// so the identity/ordering metadata is provably preserved.
|
||
pub fn content_mutation_pair() -> (Vec<OperationEnvelope>, Vec<OperationEnvelope>) {
|
||
let mut rng = Rng::new(0x00C0_1117_E27E_5EED);
|
||
let mut session = Session::new(1);
|
||
// Insert event 0 carrying pitch 0 (so the pitch is live), then respell it.
|
||
session.author(&mut rng, 0, insert_at(0, 0, 0, 0, 0));
|
||
session.author(
|
||
&mut rng,
|
||
0,
|
||
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||
pitch: obj_pitch(0),
|
||
spelling: valuegen::spelling(0xAA),
|
||
})),
|
||
);
|
||
let base = session.out;
|
||
|
||
let mut mutated = base.clone();
|
||
if let OperationPayload::Primitive(OperationKind::RespellPitch(op)) = &mut mutated[1].payload {
|
||
op.spelling = valuegen::spelling(0xBB);
|
||
} else {
|
||
unreachable!("the second op is the respelling");
|
||
}
|
||
(base, mutated)
|
||
}
|
||
|
||
/// The canonical [`CausalContext`] bytes for exactly the structurally accepted
|
||
/// operation identifiers in `envelopes`.
|
||
///
|
||
/// Each replica's contiguous prefix beginning at counter zero is represented in
|
||
/// the DVV vector; identifiers after a gap are represented as dots. Malformed
|
||
/// envelopes are excluded because [`OperationSet`] excludes them from the
|
||
/// materialized snapshot as well. The returned bytes are the operation layer's
|
||
/// real canonical encoding, not a bundle-local approximation.
|
||
pub fn frontier_bytes(envelopes: &[OperationEnvelope]) -> Vec<u8> {
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
let mut by_replica: BTreeMap<ReplicaId, BTreeSet<u64>> = BTreeMap::new();
|
||
for envelope in envelopes {
|
||
if epiphany_ops::well_formed(envelope).is_ok() {
|
||
by_replica
|
||
.entry(envelope.id.replica)
|
||
.or_default()
|
||
.insert(envelope.id.counter);
|
||
}
|
||
}
|
||
|
||
let mut frontier = CausalContext::new();
|
||
for (replica, counters) in by_replica {
|
||
let mut next = 0u64;
|
||
while counters.contains(&next) {
|
||
if next == u64::MAX {
|
||
break;
|
||
}
|
||
next += 1;
|
||
}
|
||
if next > 0 {
|
||
frontier = frontier.with_seen(replica, next - 1);
|
||
}
|
||
for counter in counters.range(next..) {
|
||
frontier = frontier.with_dot(OperationId::new(replica, *counter));
|
||
}
|
||
}
|
||
frontier.to_canonical_bytes()
|
||
}
|
||
|
||
/// A second envelope addressing the same [`OperationId`] as `env` but with
|
||
/// different canonical bytes — the input that drives a `Single` slot to
|
||
/// `Equivocated`. The stamp's id stays pinned to `env.id`, so it is still
|
||
/// well-formed.
|
||
pub fn equivocating_twin(env: &OperationEnvelope) -> OperationEnvelope {
|
||
let mut twin = env.clone();
|
||
twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||
pitch: obj_pitch(0),
|
||
spelling: valuegen::spelling(0xEE),
|
||
}));
|
||
if twin.envelope_hash() == env.envelope_hash() {
|
||
twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||
pitch: obj_pitch(1),
|
||
spelling: valuegen::spelling(0x11),
|
||
}));
|
||
}
|
||
twin
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Agent D — epiphany-bundle: refs, snapshots, blobs, a rich manifest
|
||
// ===========================================================================
|
||
|
||
/// One of the chunk kinds.
|
||
pub fn chunk_kind(rng: &mut Rng) -> ChunkKind {
|
||
*rng.choose(&[
|
||
ChunkKind::OperationEnvelopeBlock,
|
||
ChunkKind::OperationIndex,
|
||
ChunkKind::Snapshot,
|
||
ChunkKind::Blob,
|
||
ChunkKind::ExtensionData,
|
||
ChunkKind::TextProjection,
|
||
ChunkKind::LayoutCache,
|
||
ChunkKind::IntegrityIndex,
|
||
ChunkKind::Manifest,
|
||
])
|
||
}
|
||
|
||
/// A compression algorithm (every variant). Compression is metadata, not
|
||
/// identity, so any value is valid in a [`ChunkRef`].
|
||
pub fn compression_algorithm(rng: &mut Rng) -> CompressionAlgorithm {
|
||
match rng.below(3) {
|
||
0 => CompressionAlgorithm::None,
|
||
1 => CompressionAlgorithm::Zstd {
|
||
level: rng.range(1, 22) as u8,
|
||
},
|
||
_ => CompressionAlgorithm::Reserved(rng.range(2, 255) as u8),
|
||
}
|
||
}
|
||
|
||
/// A content-addressed chunk reference of the given kind, derived from a random
|
||
/// payload so its id and hash are internally consistent (content identity is the
|
||
/// uncompressed payload, independent of the `compression` metadata).
|
||
pub fn chunk_ref(rng: &mut Rng, kind: ChunkKind) -> ChunkRef {
|
||
let payload = rng.byte_vec(1, 64);
|
||
let schema = SchemaVersion::V0;
|
||
ChunkRef {
|
||
id: chunk_id(kind, schema, &payload),
|
||
kind,
|
||
schema_version: schema,
|
||
offset: rng.range(0, 1 << 32),
|
||
compressed_length: payload.len() as u64,
|
||
uncompressed_length: payload.len() as u64,
|
||
compression: compression_algorithm(rng),
|
||
hash: content_hash_for(kind, schema, &payload),
|
||
}
|
||
}
|
||
|
||
/// A committed superblock with varied fields. Committed so it parses as
|
||
/// [`epiphany_bundle::SlotParse::Valid`] for ordinary selection.
|
||
pub fn superblock(rng: &mut Rng) -> Superblock {
|
||
Superblock {
|
||
generation: rng.range(0, 1_000),
|
||
manifest_offset: rng.range(0, 1 << 32),
|
||
manifest_length: rng.range(1, 1 << 20),
|
||
manifest_hash: content_hash(rng),
|
||
manifest_schema_version: SchemaVersion::V0,
|
||
reduction_algorithm_version: ReductionAlgorithmVersion(rng.range(0, 8) as u32),
|
||
profile_id: profile_id(rng),
|
||
commit_state: CommitState::Committed,
|
||
commit_timestamp: BundleWallClockTime(rng.range(0, 1 << 40) as i64),
|
||
}
|
||
}
|
||
|
||
/// A snapshot reference (a canonical base or acceleration snapshot) under a
|
||
/// varied profile. The `hash` is the snapshot root chunk's hash (the field's
|
||
/// documented meaning: "hash of the snapshot's root chunk, for fast verification").
|
||
pub fn snapshot_ref(rng: &mut Rng) -> SnapshotRef {
|
||
let profile = profile_id(rng);
|
||
snapshot_ref_with_profile(rng, profile)
|
||
}
|
||
|
||
/// A snapshot reference whose `profile_id` is the caller's choice (so a manifest
|
||
/// can keep its snapshots' profiles among its declared profiles).
|
||
pub fn snapshot_ref_with_profile(rng: &mut Rng, profile: ProfileId) -> SnapshotRef {
|
||
let root = chunk_ref(rng, ChunkKind::Snapshot);
|
||
let hash = root.hash;
|
||
SnapshotRef {
|
||
snapshot_id: SnapshotId(rng.array16()),
|
||
covers_causal_frontier: FrontierBytes::from_bytes(rng.byte_vec(0, 24)),
|
||
reduction_algorithm_version: ReductionAlgorithmVersion(rng.range(0, 8) as u32),
|
||
profile_id: profile,
|
||
root,
|
||
hash,
|
||
}
|
||
}
|
||
|
||
/// A profile identifier (every variant).
|
||
pub fn profile_id(rng: &mut Rng) -> ProfileId {
|
||
match rng.below(4) {
|
||
0 => ProfileId::Full,
|
||
1 => ProfileId::ReadOnly,
|
||
2 => ProfileId::Lite,
|
||
_ => ProfileId::Custom(ProfileRegistryId(rng.array16())),
|
||
}
|
||
}
|
||
|
||
/// A profile identifier that is never [`ProfileId::Full`] — for a *second*
|
||
/// declaration alongside a Full one, so the two ids do not collide.
|
||
pub fn non_full_profile_id(rng: &mut Rng) -> ProfileId {
|
||
match rng.below(3) {
|
||
0 => ProfileId::ReadOnly,
|
||
1 => ProfileId::Lite,
|
||
_ => ProfileId::Custom(ProfileRegistryId(rng.array16())),
|
||
}
|
||
}
|
||
|
||
/// A blob reference with a valid RFC 6838 media type. The `hash` is the payload's
|
||
/// content hash, consistent with the content-derived `blob_id`.
|
||
pub fn blob_ref(rng: &mut Rng) -> BlobRef {
|
||
let payload = rng.byte_vec(1, 64);
|
||
let len = payload.len() as u64;
|
||
let blob_id = BlobId::of_payload(&payload);
|
||
BlobRef {
|
||
// The verification hash is the blob's content hash, consistent with its
|
||
// content-derived id.
|
||
hash: blob_id.0,
|
||
blob_id,
|
||
media_type: String::from("application/octet-stream"),
|
||
offset: rng.range(0, 1 << 32),
|
||
compressed_length: len,
|
||
uncompressed_length: len,
|
||
compression: CompressionAlgorithm::None,
|
||
declared_max_uncompressed_length: if rng.boolean() {
|
||
Some(len.max(1))
|
||
} else {
|
||
None
|
||
},
|
||
}
|
||
}
|
||
|
||
/// An extension declaration. `preserved_chunk_roots` is kept to at most one
|
||
/// entry so the canonical sort/dedup `encode` applies is a no-op and the decoded
|
||
/// value compares equal to the original.
|
||
pub fn extension_declaration(rng: &mut Rng) -> ExtensionDeclaration {
|
||
ExtensionDeclaration {
|
||
extension_id: ExtensionId(rng.array16()),
|
||
version: SemVer::new(
|
||
rng.range(0, 3) as u32,
|
||
rng.range(0, 9) as u32,
|
||
rng.range(0, 9) as u32,
|
||
),
|
||
required: rng.boolean(),
|
||
preserved_chunk_roots: if rng.boolean() {
|
||
vec![chunk_ref(rng, ChunkKind::ExtensionData)]
|
||
} else {
|
||
vec![]
|
||
},
|
||
affected_object_kinds: rng.byte_vec(0, 8),
|
||
edit_barriers: rng.byte_vec(0, 8),
|
||
}
|
||
}
|
||
|
||
/// A retention policy with varied (non-default) fields.
|
||
pub fn retention_policy(rng: &mut Rng) -> RetentionPolicy {
|
||
RetentionPolicy {
|
||
retain_previous_manifests: rng.range(0, 8) as u32,
|
||
retain_duration: if rng.boolean() {
|
||
Some(BundleWallClockDuration(rng.range(0, 1 << 40) as i64))
|
||
} else {
|
||
None
|
||
},
|
||
retain_named_checkpoints: rng.boolean(),
|
||
}
|
||
}
|
||
|
||
/// A profile declaration with a varied profile id and constraints (a varied
|
||
/// [`retention_policy`] and block-size limit).
|
||
pub fn profile_declaration(rng: &mut Rng) -> ProfileDeclaration {
|
||
let profile = profile_id(rng);
|
||
profile_declaration_with(rng, profile)
|
||
}
|
||
|
||
/// A profile declaration with the caller's `profile_id` and varied constraints.
|
||
pub fn profile_declaration_with(rng: &mut Rng, profile: ProfileId) -> ProfileDeclaration {
|
||
ProfileDeclaration {
|
||
profile_id: profile,
|
||
version: SemVer::new(rng.range(0, 2) as u32, rng.range(0, 9) as u32, 0),
|
||
constraints: ProfileConstraints {
|
||
max_uncompressed_block_size: rng.range(1 << 16, 64 << 20),
|
||
retention_policy: retention_policy(rng),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// A **rich** manifest exercising every optional field and reference vector:
|
||
/// lineage, multiple operation roots, an operation index, a canonical base,
|
||
/// acceleration snapshots, blobs, extension and (two, distinct-id) profile
|
||
/// declarations, and the text-projection / integrity optional roots. Snapshots
|
||
/// reference only declared profiles, and the two profile declarations have
|
||
/// distinct ids (no `Full`/`Full` collision).
|
||
///
|
||
/// **Scope.** This is a *codec / structural* manifest generator — it exercises
|
||
/// `Manifest::encode`/`decode` byte-stability and the decoder's validation. It is
|
||
/// not a fully bundle-valid manifest: its `ChunkRef`/`BlobRef` references are not
|
||
/// backed by chunks in any store (so `Bundle::open` + `verify_canonical_chunks`
|
||
/// would not accept it). Bundle *lifecycle* validity is covered by
|
||
/// [`crate::roundtrip::committed_manifest`] (a manifest produced by real commits)
|
||
/// and the bundle's own crash/manifest-selection harnesses.
|
||
pub fn rich_manifest(rng: &mut Rng) -> Manifest {
|
||
let mut m = Manifest::empty(DocumentId(rng.array16()));
|
||
m.generation = rng.range(0, 1_000);
|
||
m.lineage_id = if rng.boolean() {
|
||
Some(LineageId(rng.array16()))
|
||
} else {
|
||
None
|
||
};
|
||
for _ in 0..rng.range_usize(0, 3) {
|
||
m.operation_roots
|
||
.push(chunk_ref(rng, ChunkKind::OperationEnvelopeBlock));
|
||
}
|
||
m.operation_index_root = if rng.boolean() {
|
||
Some(chunk_ref(rng, ChunkKind::OperationIndex))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Two profile declarations with distinct ids (Full + a non-Full one).
|
||
let extra_id = non_full_profile_id(rng);
|
||
let extra = profile_declaration_with(rng, extra_id);
|
||
m.profile_declarations = vec![ProfileDeclaration::full(), extra];
|
||
// Snapshots reference only declared profiles (Full or the extra).
|
||
let declared = [ProfileId::Full, extra_id];
|
||
m.canonical_base = if rng.boolean() {
|
||
let p = *rng.choose(&declared);
|
||
Some(snapshot_ref_with_profile(rng, p))
|
||
} else {
|
||
None
|
||
};
|
||
for _ in 0..rng.range_usize(0, 2) {
|
||
let p = *rng.choose(&declared);
|
||
m.acceleration_snapshots
|
||
.push(snapshot_ref_with_profile(rng, p));
|
||
}
|
||
for _ in 0..rng.range_usize(0, 2) {
|
||
m.blob_roots.push(blob_ref(rng));
|
||
}
|
||
for _ in 0..rng.range_usize(0, 2) {
|
||
m.extension_declarations.push(extension_declaration(rng));
|
||
}
|
||
m.text_projection_root = if rng.boolean() {
|
||
Some(chunk_ref(rng, ChunkKind::TextProjection))
|
||
} else {
|
||
None
|
||
};
|
||
m.integrity_root = if rng.boolean() {
|
||
Some(chunk_ref(rng, ChunkKind::IntegrityIndex))
|
||
} else {
|
||
None
|
||
};
|
||
m
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn envelope_set_is_well_formed_and_deterministic() {
|
||
let mut a = Rng::new(42);
|
||
let mut b = Rng::new(42);
|
||
let sa = operation_envelopes(&mut a, 64, 3, 6, 6);
|
||
let sb = operation_envelopes(&mut b, 64, 3, 6, 6);
|
||
assert_eq!(sa.len(), 64);
|
||
assert_eq!(sa, sb, "generation must be reproducible from the seed");
|
||
assert!(sa.iter().all(|e| epiphany_ops::well_formed(e).is_ok()));
|
||
}
|
||
|
||
#[test]
|
||
fn equivocating_twin_differs_in_bytes_but_not_id() {
|
||
let mut rng = Rng::new(7);
|
||
let set = operation_envelopes(&mut rng, 4, 2, 6, 6);
|
||
let twin = equivocating_twin(&set[0]);
|
||
assert_eq!(twin.id, set[0].id);
|
||
assert_eq!(twin.stamp.id, set[0].id);
|
||
assert_ne!(twin.envelope_hash(), set[0].envelope_hash());
|
||
}
|
||
|
||
#[test]
|
||
fn rational_large_is_actually_promoted() {
|
||
let mut rng = Rng::new(3);
|
||
for _ in 0..100 {
|
||
let r = rational_time_large(&mut rng);
|
||
assert!(
|
||
matches!(r, RationalTime::Large(_)),
|
||
"rational_time_large must produce the promoted arm, got {r:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rich_manifest_is_deterministic() {
|
||
let mut a = Rng::new(99);
|
||
let mut b = Rng::new(99);
|
||
assert_eq!(rich_manifest(&mut a), rich_manifest(&mut b));
|
||
}
|
||
|
||
#[test]
|
||
fn zero_sized_operation_spaces_are_supported() {
|
||
let mut rng = Rng::new(5);
|
||
for _ in 0..128 {
|
||
let _ = operation_payload(&mut rng, 0, 0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn frontier_uses_the_real_dvv_encoding_and_preserves_gaps() {
|
||
let mut rng = Rng::new(11);
|
||
let mut envelope = operation_envelopes(&mut rng, 1, 1, 1, 1).remove(0);
|
||
envelope.id = OperationId::new(ReplicaId(1), 5);
|
||
envelope.stamp.id = envelope.id;
|
||
let expected = CausalContext::new()
|
||
.with_dot(envelope.id)
|
||
.to_canonical_bytes();
|
||
assert_eq!(frontier_bytes(std::slice::from_ref(&envelope)), expected);
|
||
|
||
envelope.stamp.hlc.physical_time = WallClockTime(-1);
|
||
assert_eq!(
|
||
frontier_bytes(&[envelope]),
|
||
CausalContext::new().to_canonical_bytes(),
|
||
"rejected envelopes must not enter the snapshot frontier"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn generated_reduction_outputs_decode_canonically() {
|
||
for seed in 0..256 {
|
||
let mut rng = Rng::new(seed);
|
||
let mut state = MaterializedState::default();
|
||
state
|
||
.effects
|
||
.push((operation_id(&mut rng), operation_effect(&mut rng)));
|
||
state.conflicts = conflict_registry(&mut rng);
|
||
state.anomalies.push(integrity_anomaly(&mut rng));
|
||
state
|
||
.objects
|
||
.insert(typed_object_id(&mut rng), object_state(&mut rng));
|
||
state
|
||
.spellings
|
||
.insert(pitch_id(&mut rng), valuegen::spelling(rng.below(7) as u8));
|
||
state.breaks.insert(
|
||
(region_id(&mut rng), musical_position(&mut rng)),
|
||
rng.boolean(),
|
||
);
|
||
state
|
||
.pending
|
||
.push((operation_id(&mut rng), pending_reason(&mut rng)));
|
||
|
||
let bytes = state.canonical_bytes();
|
||
let decoded = MaterializedState::decode_canonical(&bytes).unwrap();
|
||
assert_eq!(decoded.canonical_bytes(), bytes);
|
||
|
||
let _ = reanchor_result(&mut rng);
|
||
let _ = anomalous_replica_segment(&mut rng);
|
||
}
|
||
}
|
||
}
|