diff --git a/research/cultnet-distributed-database/summary.md b/research/cultnet-distributed-database/summary.md index 893ab3c3..40a0b19d 100644 --- a/research/cultnet-distributed-database/summary.md +++ b/research/cultnet-distributed-database/summary.md @@ -174,6 +174,7 @@ Shard catalog exchange now exists: - client authority scopes for predicted local input documents - predicted/reconciled change events for client-side prediction - simulation witness observations and deterministic consensus candidates +- schema-v0 simulation observation and candidate messages ## Next Cut @@ -184,7 +185,7 @@ Build durable authoritative mutation logs: - return explicit resync-required responses when requested history is gone - then add simulation-frame rollback and resimulation helpers on top of predicted input streams -- add observation gossip once the local witness model has earned its shape +- add an observation hub that receives gossip and emits consensus candidates That is the next foundation needed before membership, Raft-style failover, or CRDT policy work can be honest. diff --git a/src/GameCult.Networking/Contracts/cultnet-distributed-database.md b/src/GameCult.Networking/Contracts/cultnet-distributed-database.md index c566467a..6cbaf72f 100644 --- a/src/GameCult.Networking/Contracts/cultnet-distributed-database.md +++ b/src/GameCult.Networking/Contracts/cultnet-distributed-database.md @@ -63,6 +63,7 @@ Implemented: - predicted and reconciled database change kinds for client-side prediction - simulation witness observations for mesh-side opinions about frame facts - deterministic consensus candidates derived from witness observations +- schema-v0 simulation observation and consensus candidate messages Not implemented yet: @@ -70,7 +71,7 @@ Not implemented yet: - leader election or automatic shard failover - durable log persistence and compaction - snapshot fallback for compacted log history -- transport messages/documents for witness observation gossip +- observation hub/fanout that receives gossip and emits candidates reactively - rollback/resimulation helpers for simulation frames - declared CRDT merge policies @@ -105,14 +106,15 @@ Add durable mutation-log storage and snapshot fallback: - return `ResyncRequired` when a replica asks for compacted history - after that, add simulation-frame rollback/resimulation helpers around predicted input streams -- add schema-v0 observation gossip once the local consensus document shape is - stable +- add an observation hub that receives schema-v0 observation messages and emits + consensus candidates reactively The log is wire-readable, replicas can apply it explicitly, a pull loop can drive non-primary shards from primary endpoints, and replica cursors can survive restart. Client-owned input documents can now be predicted locally and reconciled when the authoritative log arrives. Nodes can also aggregate witness observations into deterministic consensus candidates for simulation facts like -"who shot first." The next useful layer is making authoritative mutation logs -durable and compaction-aware. Membership and leader election should wait until -basic replica catch-up is boring. +"who shot first," and those reports now have schema-v0 wire contracts. The next +useful layer is making authoritative mutation logs durable and compaction-aware. +Membership and leader election should wait until basic replica catch-up is +boring. diff --git a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs index 786b3868..3faebce7 100644 --- a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs +++ b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs @@ -66,6 +66,8 @@ namespace GameCult.Networking CultNetSchemaVersions.ShardCatalogResponse => Deserialize(payload), CultNetSchemaVersions.ShardLogRequest => Deserialize(payload), CultNetSchemaVersions.ShardLogResponse => Deserialize(payload), + CultNetSchemaVersions.SimulationObservation => Deserialize(payload), + CultNetSchemaVersions.SimulationConsensusCandidate => Deserialize(payload), CultNetSchemaVersions.SchemaCatalogRequest => Deserialize(payload), CultNetSchemaVersions.SchemaCatalogResponse => Deserialize(payload), _ => throw new MessagePackSerializationException( diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index e09bf3ba..8cbac055 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -107,6 +107,14 @@ namespace GameCult.Networking /// shard mutation log response contract identifier. /// public const string ShardLogResponse = "cultnet.shard_log_response.v0"; + /// + /// simulation observation gossip contract identifier. + /// + public const string SimulationObservation = "cultnet.simulation_observation.v0"; + /// + /// simulation consensus candidate gossip contract identifier. + /// + public const string SimulationConsensusCandidate = "cultnet.simulation_consensus_candidate.v0"; } /// @@ -815,6 +823,116 @@ namespace GameCult.Networking [Key("delete")] public CultNetDocumentDeleteMessage? Delete { get; set; } } + /// + /// Carries one witness observation about a simulation fact. + /// + [MessagePackObject] + public class CultNetSimulationObservationMessage : ICultNetSchemaMessage + { + /// + /// Gets or sets the schema version. + /// + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SimulationObservation; + /// + /// Gets or sets the message id. + /// + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + /// + /// Gets or sets the observation. + /// + [Key("observation")] public CultNetSimulationObservation Observation { get; set; } = new CultNetSimulationObservation(); + } + + /// + /// Carries one deterministic consensus candidate derived from witness observations. + /// + [MessagePackObject] + public class CultNetSimulationConsensusCandidateMessage : ICultNetSchemaMessage + { + /// + /// Gets or sets the schema version. + /// + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SimulationConsensusCandidate; + /// + /// Gets or sets the message id. + /// + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + /// + /// Gets or sets the shard id. + /// + [Key("shardId")] public string ShardId { get; set; } = string.Empty; + /// + /// Gets or sets the shard epoch. + /// + [Key("shardEpoch")] public long ShardEpoch { get; set; } + /// + /// Gets or sets the simulation frame. + /// + [Key("frame")] public long Frame { get; set; } + /// + /// Gets or sets the subject id. + /// + [Key("subjectId")] public string SubjectId { get; set; } = string.Empty; + /// + /// Gets or sets the claim kind. + /// + [Key("claimKind")] public string ClaimKind { get; set; } = string.Empty; + /// + /// Gets or sets the selected claim hash. + /// + [Key("claimHash")] public string ClaimHash { get; set; } = string.Empty; + /// + /// Gets or sets optional claim detail. + /// + [Key("claimSummary")] public string? ClaimSummary { get; set; } + /// + /// Gets or sets distinct supporting witnesses. + /// + [Key("witnessCount")] public int WitnessCount { get; set; } + /// + /// Gets or sets supporting witness weight. + /// + [Key("supportWeight")] public double SupportWeight { get; set; } + /// + /// Gets or sets total observed witness weight. + /// + [Key("totalWeight")] public double TotalWeight { get; set; } + /// + /// Gets or sets whether the candidate crossed quorum. + /// + [Key("hasQuorum")] public bool HasQuorum { get; set; } + /// + /// Gets or sets support divided by total observed weight. + /// + [Key("confidence")] public double Confidence { get; set; } + + /// + /// Creates a wire candidate message from a local candidate. + /// + public static CultNetSimulationConsensusCandidateMessage FromCandidate( + string messageId, + CultNetSimulationConsensusCandidate candidate) + { + if (candidate == null) throw new ArgumentNullException(nameof(candidate)); + return new CultNetSimulationConsensusCandidateMessage + { + MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId, + ShardId = candidate.ShardId, + ShardEpoch = candidate.ShardEpoch, + Frame = candidate.Frame, + SubjectId = candidate.SubjectId, + ClaimKind = candidate.ClaimKind, + ClaimHash = candidate.ClaimHash, + ClaimSummary = candidate.ClaimSummary, + WitnessCount = candidate.WitnessCount, + SupportWeight = candidate.SupportWeight, + TotalWeight = candidate.TotalWeight, + HasQuorum = candidate.HasQuorum, + Confidence = candidate.Confidence + }; + } + } + /// /// Describes one schema exposed through the CultNet schema catalog. /// diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md index f2f9ab34..d1edf3f1 100644 --- a/src/GameCult.Networking/README.md +++ b/src/GameCult.Networking/README.md @@ -159,7 +159,9 @@ a witness saw for one shard epoch, frame, subject, and claim kind. `CultNetSimulationConsensus` deterministically aggregates those observations into candidates with support weight, total observed weight, confidence, and quorum status. Those candidates are opinions ready for an authoritative commit -path, not committed world state by themselves. +path, not committed world state by themselves. The schema-v0 wire contracts are +`cultnet.simulation_observation.v0` and +`cultnet.simulation_consensus_candidate.v0`. The first coherent shard policy is primary ownership. Each shard has one authoritative writer at a time. Clients may connect to any node; a non-owner diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index c94439ae..1d87efae 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -319,6 +319,65 @@ namespace GameCult.Networking.Tests Assert.That(roundTrip.Entries[0].Put!.Document.RecordKey, Is.EqualTo("player:42")); } + [Test] + public void CultNetSchemaMessageSerialization_RoundTrips_SimulationObservation() + { + var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100"); + var message = new CultNetSimulationObservationMessage + { + MessageId = "observation-1", + Observation = new CultNetSimulationObservation + { + WitnessRuntimeId = "watcher-1", + ShardId = "arena", + ShardEpoch = 4, + Frame = 100, + SubjectId = "bob", + ClaimKind = "hit", + ClaimHash = claimHash, + ClaimSummary = "alice hit bob first", + ObservedAt = "2026-05-19T12:00:00.0000000Z" + } + }; + + var payload = CultNetSchemaMessageSerialization.Serialize(message); + var roundTrip = (CultNetSimulationObservationMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(roundTrip.MessageId, Is.EqualTo("observation-1")); + Assert.That(roundTrip.Observation.WitnessRuntimeId, Is.EqualTo("watcher-1")); + Assert.That(roundTrip.Observation.Frame, Is.EqualTo(100)); + Assert.That(roundTrip.Observation.ClaimHash, Is.EqualTo(claimHash)); + } + + [Test] + public void CultNetSchemaMessageSerialization_RoundTrips_SimulationConsensusCandidate() + { + var candidate = new CultNetSimulationConsensusCandidate( + "arena", + 4, + 100, + "bob", + "hit", + "claim-hash", + "alice hit bob first", + witnessCount: 3, + supportWeight: 3d, + totalWeight: 4d, + hasQuorum: true); + + var message = CultNetSimulationConsensusCandidateMessage.FromCandidate("candidate-1", candidate); + var payload = CultNetSchemaMessageSerialization.Serialize(message); + var roundTrip = (CultNetSimulationConsensusCandidateMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(roundTrip.MessageId, Is.EqualTo("candidate-1")); + Assert.That(roundTrip.ShardId, Is.EqualTo("arena")); + Assert.That(roundTrip.WitnessCount, Is.EqualTo(3)); + Assert.That(roundTrip.SupportWeight, Is.EqualTo(3d)); + Assert.That(roundTrip.TotalWeight, Is.EqualTo(4d)); + Assert.That(roundTrip.Confidence, Is.EqualTo(0.75d)); + Assert.That(roundTrip.HasQuorum, Is.True); + } + [Test] public async Task CultNetDocumentRegistry_RawSnapshotReplication_PreservesPayloadBytes() {