Add CultNet simulation observation wire contracts

This commit is contained in:
Meta Vens 2026-05-19 18:39:53 +01:00
commit f99edc5c43
6 changed files with 192 additions and 8 deletions

View file

@ -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.

View file

@ -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.

View file

@ -66,6 +66,8 @@ namespace GameCult.Networking
CultNetSchemaVersions.ShardCatalogResponse => Deserialize<CultNetShardCatalogResponseMessage>(payload),
CultNetSchemaVersions.ShardLogRequest => Deserialize<CultNetShardLogRequestMessage>(payload),
CultNetSchemaVersions.ShardLogResponse => Deserialize<CultNetShardLogResponseMessage>(payload),
CultNetSchemaVersions.SimulationObservation => Deserialize<CultNetSimulationObservationMessage>(payload),
CultNetSchemaVersions.SimulationConsensusCandidate => Deserialize<CultNetSimulationConsensusCandidateMessage>(payload),
CultNetSchemaVersions.SchemaCatalogRequest => Deserialize<CultNetSchemaCatalogRequestMessage>(payload),
CultNetSchemaVersions.SchemaCatalogResponse => Deserialize<CultNetSchemaCatalogResponseMessage>(payload),
_ => throw new MessagePackSerializationException(

View file

@ -107,6 +107,14 @@ namespace GameCult.Networking
/// shard mutation log response contract identifier.
/// </summary>
public const string ShardLogResponse = "cultnet.shard_log_response.v0";
/// <summary>
/// simulation observation gossip contract identifier.
/// </summary>
public const string SimulationObservation = "cultnet.simulation_observation.v0";
/// <summary>
/// simulation consensus candidate gossip contract identifier.
/// </summary>
public const string SimulationConsensusCandidate = "cultnet.simulation_consensus_candidate.v0";
}
/// <summary>
@ -815,6 +823,116 @@ namespace GameCult.Networking
[Key("delete")] public CultNetDocumentDeleteMessage? Delete { get; set; }
}
/// <summary>
/// Carries one witness observation about a simulation fact.
/// </summary>
[MessagePackObject]
public class CultNetSimulationObservationMessage : ICultNetSchemaMessage
{
/// <summary>
/// Gets or sets the schema version.
/// </summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SimulationObservation;
/// <summary>
/// Gets or sets the message id.
/// </summary>
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the observation.
/// </summary>
[Key("observation")] public CultNetSimulationObservation Observation { get; set; } = new CultNetSimulationObservation();
}
/// <summary>
/// Carries one deterministic consensus candidate derived from witness observations.
/// </summary>
[MessagePackObject]
public class CultNetSimulationConsensusCandidateMessage : ICultNetSchemaMessage
{
/// <summary>
/// Gets or sets the schema version.
/// </summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SimulationConsensusCandidate;
/// <summary>
/// Gets or sets the message id.
/// </summary>
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the shard id.
/// </summary>
[Key("shardId")] public string ShardId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the shard epoch.
/// </summary>
[Key("shardEpoch")] public long ShardEpoch { get; set; }
/// <summary>
/// Gets or sets the simulation frame.
/// </summary>
[Key("frame")] public long Frame { get; set; }
/// <summary>
/// Gets or sets the subject id.
/// </summary>
[Key("subjectId")] public string SubjectId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the claim kind.
/// </summary>
[Key("claimKind")] public string ClaimKind { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the selected claim hash.
/// </summary>
[Key("claimHash")] public string ClaimHash { get; set; } = string.Empty;
/// <summary>
/// Gets or sets optional claim detail.
/// </summary>
[Key("claimSummary")] public string? ClaimSummary { get; set; }
/// <summary>
/// Gets or sets distinct supporting witnesses.
/// </summary>
[Key("witnessCount")] public int WitnessCount { get; set; }
/// <summary>
/// Gets or sets supporting witness weight.
/// </summary>
[Key("supportWeight")] public double SupportWeight { get; set; }
/// <summary>
/// Gets or sets total observed witness weight.
/// </summary>
[Key("totalWeight")] public double TotalWeight { get; set; }
/// <summary>
/// Gets or sets whether the candidate crossed quorum.
/// </summary>
[Key("hasQuorum")] public bool HasQuorum { get; set; }
/// <summary>
/// Gets or sets support divided by total observed weight.
/// </summary>
[Key("confidence")] public double Confidence { get; set; }
/// <summary>
/// Creates a wire candidate message from a local candidate.
/// </summary>
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
};
}
}
/// <summary>
/// Describes one schema exposed through the CultNet schema catalog.
/// </summary>

View file

@ -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

View file

@ -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()
{