using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Networking
{
///
/// The four comparison and one any-of operator a field predicate may carry. Serialised on the wire
/// as the op string (), never as a union: the C#
/// enum exists for evaluator and validation ergonomics only.
///
public enum CultNetSelectionOperator
{
/// Matches when the row's declared value is one of values.
AnyOf,
/// Matches when the row's declared numeric value is less than number.
Lt,
/// Matches when the row's declared numeric value is less than or equal to number.
Le,
/// Matches when the row's declared numeric value is greater than or equal to number.
Ge,
/// Matches when the row's declared numeric value is greater than number.
Gt
}
/// Converts to and from its wire spelling.
public static class CultNetSelectionOperators
{
/// Returns the wire spelling of an operator.
public static string ToWireString(this CultNetSelectionOperator op) => op switch
{
CultNetSelectionOperator.AnyOf => "any_of",
CultNetSelectionOperator.Lt => "lt",
CultNetSelectionOperator.Le => "le",
CultNetSelectionOperator.Ge => "ge",
CultNetSelectionOperator.Gt => "gt",
_ => throw new ArgumentOutOfRangeException(nameof(op))
};
/// Parses a wire operator string; returns false for anything else.
public static bool TryParse(string? wire, out CultNetSelectionOperator op)
{
switch (wire)
{
case "any_of": op = CultNetSelectionOperator.AnyOf; return true;
case "lt": op = CultNetSelectionOperator.Lt; return true;
case "le": op = CultNetSelectionOperator.Le; return true;
case "ge": op = CultNetSelectionOperator.Ge; return true;
case "gt": op = CultNetSelectionOperator.Gt; return true;
default: op = default; return false;
}
}
/// True for the four comparison operators (everything but any_of).
public static bool IsComparison(this CultNetSelectionOperator op) => op != CultNetSelectionOperator.AnyOf;
}
///
/// The wire's canonical decimal form (Q-J, docs/cultnet-selection-cut.md section 2 "Numbers"): one
/// spelling per value, so the cursor digest and the cross-runtime parity vectors need no numeric
/// parser and no precision limit. A comparison number that does not match , or
/// that spells zero with a leading minus, is refused at the door rather than normalised - a
/// non-canonical spelling of an equal value never reaches the evaluator.
///
public static class CultNetCanonicalNumber
{
///
/// Forbids leading zeros, a trailing fractional zero or bare point, an explicit '+', exponent
/// notation, and whitespace. Does not by itself forbid "-0"; does.
/// Anchored with \z, not $: .NET's $ matches immediately before a single
/// trailing newline even without ,
/// so a naive $ here would accept "5\n" as canonical (R-D/Q-J; the JSON schema's ECMA-262
/// $ has no such exception and already refused it).
///
public const string Pattern = @"\A-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?\z";
private static readonly Regex CanonicalRegex = new(Pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// True for a canonical decimal string; false for null, "-0", or any other spelling.
public static bool IsCanonical(string? value) =>
value != null && value != "-0" && CanonicalRegex.IsMatch(value);
///
/// Compares two canonical decimal strings by sign, then the integer part's length, then its
/// digits, then the fraction digits padded on the right to equal length - a pure string
/// comparison, never a numeric parse, so no CLR type's range limits what a comparison can hold.
/// Callers must validate both operands with first; this does not
/// re-validate.
///
public static int Compare(string left, string right)
{
var (negativeLeft, integerLeft, fractionLeft) = Decompose(left);
var (negativeRight, integerRight, fractionRight) = Decompose(right);
if (negativeLeft != negativeRight)
return negativeLeft ? -1 : 1;
var sign = negativeLeft ? -1 : 1;
if (integerLeft.Length != integerRight.Length)
return sign * (integerLeft.Length < integerRight.Length ? -1 : 1);
var integerCompare = string.CompareOrdinal(integerLeft, integerRight);
if (integerCompare != 0)
return sign * Math.Sign(integerCompare);
var fractionLength = Math.Max(fractionLeft.Length, fractionRight.Length);
var fractionCompare = string.CompareOrdinal(
fractionLeft.PadRight(fractionLength, '0'),
fractionRight.PadRight(fractionLength, '0'));
return sign * Math.Sign(fractionCompare);
}
private static (bool Negative, string Integer, string Fraction) Decompose(string canonical)
{
var negative = canonical.Length > 0 && canonical[0] == '-';
var unsigned = negative ? canonical.Substring(1) : canonical;
var dot = unsigned.IndexOf('.');
return dot < 0
? (negative, unsigned, string.Empty)
: (negative, unsigned.Substring(0, dot), unsigned.Substring(dot + 1));
}
}
/// One predicate over a single declared index alias (docs/cultnet-selection-cut.md, section 2).
[MessagePackObject]
public sealed class CultNetFieldPredicate
{
/// A declared index alias, reachable on some schema the selection can reach.
[Key("index")] public string Index { get; set; } = string.Empty;
/// "any_of", "lt", "le", "ge", or "gt".
[Key("op")] public string Op { get; set; } = string.Empty;
/// Present iff op = any_of; non-empty.
[Key("values")] public string[]? Values { get; set; }
/// Present iff op is a comparison; a canonical decimal string (Q-J, ).
[Key("number")] public string? Number { get; set; }
}
/// Identifies one row on the wire by schema and record key.
[MessagePackObject]
public sealed class CultNetRecordRef
{
/// Gets or sets the schema id.
[Key("schemaId")] public string SchemaId { get; set; } = string.Empty;
/// Gets or sets the record key.
[Key("recordKey")] public string RecordKey { get; set; } = string.Empty;
}
/// Selects rows whose declared reference names a target row (docs/cultnet-selection-cut.md, section 2).
[MessagePackObject]
public sealed class CultNetCitation
{
/// The row the citer must name.
[Key("target")] public CultNetRecordRef Target { get; set; } = new();
/// The reference's declared alias; absent matches any declared reference.
[Key("role")] public string? Role { get; set; }
}
/// Selects rows some other row does or does not name in role - the one negation.
[MessagePackObject]
public sealed class CultNetIncoming
{
/// The declared reference alias to check for an incoming edge.
[Key("role")] public string Role { get; set; } = string.Empty;
/// True to select rows with an incoming edge, false to select rows without one.
[Key("exists")] public bool Exists { get; set; }
}
///
/// One typed selection, carried by cultnet.snapshot_request.v1 and
/// cultnet.database_subscribe.v1. docs/cultnet-selection-cut.md, section 2.
///
[MessagePackObject]
public sealed class CultNetSelection
{
/// Kinds: schema ids or aliases, matched by . Absent = every schema.
[Key("schemas")] public string[]? Schemas { get; set; }
/// Record-key allowlist. Absent = every key.
[Key("keys")] public string[]? Keys { get; set; }
/// Conjunction; each is any-of over one declared index alias.
[Key("fields")] public CultNetFieldPredicate[]? Fields { get; set; }
/// Rows whose declared reference names target.
[Key("cites")] public CultNetCitation? Cites { get; set; }
/// Rows some row does or does not name in role.
[Key("cited")] public CultNetIncoming? Cited { get; set; }
/// "header" or "document"; default "header".
[Key("projection")] public string Projection { get; set; } = CultNetSelectionProjections.Header;
/// Reverses the one order.
[Key("descending")] public bool Descending { get; set; }
/// Clamped 1..=200.
[Key("limit")] public uint? Limit { get; set; }
/// Opaque; minted by the answering server.
[Key("cursor")] public string? Cursor { get; set; }
/// True when this selection follows an edge (section 2: "Edges in the answer").
[IgnoreMember]
public bool HasHop => Cites != null || Cited != null;
}
///
/// Lowers a v0 schemaIds/recordKeys filter into cultnet.selection.v1's null-means-
/// every vocabulary (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0's old cleaning
/// carried over as the one place that decides it. An absent list, an empty one, or one made only
/// of blank entries all lower to null; a mixed list keeps its non-blank entries, deduplicated.
/// This is a v0-compatibility rule, not a meaning or
/// carries - v1 selections are refused at the door
/// instead (S1).
///
public static class CultNetV0SelectionLowering
{
///
/// Lowers one v0 filter list; see the type summary. R-R (2026-09-22): a null list means
/// "no filter" and stays null; a non-null list, even one that filters down to nothing,
/// stays a real (possibly empty) array. Collapsing an explicit empty list to "no filter" was
/// wrong - the pre-cut reference (CultNetDatabase.CreateShardSnapshotResponse at
/// b3d9cf7) tests filter?.RecordKeys != null, not a length, so
/// recordKeys: [] always answered empty. The evaluator already honours that distinction
/// (CultNetSelectionEvaluator: a non-null Keys array excludes every row when it
/// is empty); only this lowering used to erase it before the selection ever reached the
/// evaluator.
///
public static string[]? Lower(IReadOnlyList? values)
{
if (values == null)
return null;
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
}
}
/// The two wire values may carry.
public static class CultNetSelectionProjections
{
/// The record without its payload.
public const string Header = "header";
/// The full record.
public const string Document = "document";
}
/// One edge a hop traversed, carried on .
[MessagePackObject]
public sealed class CultNetEdge
{
/// The citing row.
[Key("from")] public CultNetRecordRef From { get; set; } = new();
/// The declared reference alias the citer used.
[Key("role")] public string Role { get; set; } = string.Empty;
/// The cited row, its schema resolved to the leaf actually stored.
[Key("to")] public CultNetRecordRef To { get; set; } = new();
/// "messagepack" when is present.
[Key("payloadEncoding")] public string? PayloadEncoding { get; set; }
/// The value attached to the edge (a dictionary reference's value); present under document projection only.
[Key("payload")] public byte[]? Payload { get; set; }
}
///
/// A header record: minus its payload and payloadEncoding.
///
[MessagePackObject]
public sealed class CultNetRawDocumentHeader
{
[Key("schemaId")] public string SchemaId { get; set; } = string.Empty;
[Key("schemaName")] public string? SchemaName { get; set; }
[Key("schemaVersion")] public string? SchemaVersion { get; set; }
[Key("schemaContentHash")] public string? SchemaContentHash { get; set; }
[Key("recordKey")] public string RecordKey { get; set; } = string.Empty;
[Key("storedAt")] public string StoredAt { get; set; } = string.Empty;
[Key("sourceRuntimeId")] public string? SourceRuntimeId { get; set; }
[Key("sourceAgentId")] public string? SourceAgentId { get; set; }
[Key("sourceRole")] public string? SourceRole { get; set; }
[Key("tags")] public string[]? Tags { get; set; }
/// Builds a header by dropping a raw record's payload.
public static CultNetRawDocumentHeader FromRecord(CultNetRawDocumentRecord record) => new()
{
SchemaId = record.SchemaId,
SchemaName = record.SchemaName,
SchemaVersion = record.SchemaVersion,
SchemaContentHash = record.SchemaContentHash,
RecordKey = record.RecordKey,
StoredAt = record.StoredAt,
SourceRuntimeId = record.SourceRuntimeId,
SourceAgentId = record.SourceAgentId,
SourceRole = record.SourceRole,
Tags = record.Tags
};
}
///
/// The result of evaluating a selection, before it is carried by a wire message. Exactly one of
/// / is populated, by projection.
///
public sealed class CultNetSelectionPage
{
public uint Matched { get; set; }
public ulong AsOf { get; set; }
public string? Next { get; set; }
public CultNetRawDocumentHeader[]? Headers { get; set; }
public CultNetRawDocumentRecord[]? Documents { get; set; }
public CultNetEdge[]? Edges { get; set; }
}
///
/// Refused typed at the door (section 2/3): a name, list, or comparison the declared shape cannot
/// support. Carried as cultnet.error.v0 { code: "selection_invalid", details: { field, value } }.
///
public sealed class CultNetSelectionInvalidException : Exception
{
public CultNetSelectionInvalidException(string field, string? value, string message)
: base(message)
{
Field = field;
Value = value;
}
/// The selection field that failed ("schemas", "keys", "fields[i].index", "cites.role", "cited.role", ...).
public string Field { get; }
/// The offending value, when there is one string worth naming.
public string? Value { get; }
}
///
/// Validates a against the declared shape of the schemas it can
/// reach - the door refusals of docs/cultnet-selection-cut.md section 2/3 (S1).
///
public static class CultNetSelectionValidation
{
///
/// The declaration-independent half of the door: empty/blank schemas/keys and an
/// unrecognised projection. Runs first inside , and is also the
/// whole door for a caller with no descriptor list in hand - CultMesh's
/// EnsureV0Compatible (docs/cultnet-selection-cut.md, R-F) runs this before its own v0
/// transport refusals, so Keys=[]/[""] is refused there too, not only in the
/// reference runtime's full evaluator path.
///
public static void ValidateShape(CultNetSelection selection)
{
if (selection == null) throw new ArgumentNullException(nameof(selection));
if (selection.Schemas != null && selection.Schemas.Length == 0)
throw new CultNetSelectionInvalidException("schemas", null, "selection.schemas is present and empty; omit it to reach every schema.");
if (selection.Keys != null && selection.Keys.Length == 0)
throw new CultNetSelectionInvalidException("keys", null, "selection.keys is present and empty; omit it to reach every key.");
if (selection.Schemas != null)
{
foreach (var schema in selection.Schemas)
{
if (string.IsNullOrWhiteSpace(schema))
throw new CultNetSelectionInvalidException("schemas", schema, "selection.schemas carries an empty or whitespace entry.");
}
}
if (selection.Keys != null)
{
foreach (var key in selection.Keys)
{
if (string.IsNullOrWhiteSpace(key))
throw new CultNetSelectionInvalidException("keys", key, "selection.keys carries an empty or whitespace entry.");
}
}
if (selection.Projection is not (CultNetSelectionProjections.Header or CultNetSelectionProjections.Document))
throw new CultNetSelectionInvalidException("projection", selection.Projection, $"selection.projection \"{selection.Projection}\" is neither \"header\" nor \"document\".");
}
public static void Validate(this CultNetSelection selection, IReadOnlyList allDescriptors)
{
if (allDescriptors == null) throw new ArgumentNullException(nameof(allDescriptors));
ValidateShape(selection);
var reachable = ReachableSchemas(selection, allDescriptors);
if (selection.Fields != null)
{
for (var i = 0; i < selection.Fields.Length; i++)
{
ValidateField(selection.Fields[i], i, reachable);
}
}
if (selection.Cites != null)
{
if (string.IsNullOrEmpty(selection.Cites.Target?.SchemaId) || string.IsNullOrEmpty(selection.Cites.Target?.RecordKey))
throw new CultNetSelectionInvalidException("cites.target", null, "selection.cites.target requires schemaId and recordKey.");
// R-E (docs/cultnet-selection-cut.md): a cites target names a schema through the same
// alias matcher `schemas` uses. One that matches no declared schema is refused here,
// never answered with an empty page by MatchesCitation silently treating an unresolved
// target as "not found".
if (!allDescriptors.Any(descriptor => CultNetSchemaAliasMatching.Matches(selection.Cites.Target!.SchemaId, descriptor)))
throw new CultNetSelectionInvalidException("cites.target.schemaId", selection.Cites.Target!.SchemaId, $"selection.cites.target.schemaId \"{selection.Cites.Target!.SchemaId}\" does not match any declared schema.");
if (selection.Cites.Role != null && !AnySchemaDeclaresRole(allDescriptors, selection.Cites.Role))
throw new CultNetSelectionInvalidException("cites.role", selection.Cites.Role, $"selection.cites.role \"{selection.Cites.Role}\" is not declared by any schema.");
}
if (selection.Cited != null)
{
if (string.IsNullOrEmpty(selection.Cited.Role))
throw new CultNetSelectionInvalidException("cited.role", null, "selection.cited.role must be non-empty.");
if (!AnySchemaDeclaresRole(allDescriptors, selection.Cited.Role))
throw new CultNetSelectionInvalidException("cited.role", selection.Cited.Role, $"selection.cited.role \"{selection.Cited.Role}\" is not declared by any schema.");
}
}
private static void ValidateField(CultNetFieldPredicate field, int index, IReadOnlyList reachable)
{
var prefix = $"fields[{index}]";
if (string.IsNullOrEmpty(field.Index))
throw new CultNetSelectionInvalidException($"{prefix}.index", null, $"{prefix}.index must be non-empty.");
if (!CultNetSelectionOperators.TryParse(field.Op, out var op))
throw new CultNetSelectionInvalidException($"{prefix}.op", field.Op, $"{prefix}.op \"{field.Op}\" is not any_of/lt/le/ge/gt.");
if (op == CultNetSelectionOperator.AnyOf)
{
if (field.Values == null || field.Values.Length == 0)
throw new CultNetSelectionInvalidException($"{prefix}.values", null, $"{prefix}.values must be non-empty for op any_of.");
if (field.Number != null)
throw new CultNetSelectionInvalidException($"{prefix}.number", null, $"{prefix} carries both values and number; any_of takes only values.");
}
else
{
if (field.Number == null)
throw new CultNetSelectionInvalidException($"{prefix}.number", null, $"{prefix}.number is required for op {field.Op}.");
if (!CultNetCanonicalNumber.IsCanonical(field.Number))
throw new CultNetSelectionInvalidException($"{prefix}.number", field.Number, $"{prefix}.number \"{field.Number}\" is not a canonical decimal string (Q-J): it must match {CultNetCanonicalNumber.Pattern} and not be \"-0\".");
if (field.Values != null)
throw new CultNetSelectionInvalidException($"{prefix}.values", null, $"{prefix} carries both values and number; a comparison takes only number.");
}
var declaring = reachable
.Select(descriptor => descriptor.DeclaredMembers.FirstOrDefault(member => member.IndexAlias == field.Index))
.Where(member => member.MemberName != null)
.ToArray();
if (declaring.Length == 0)
throw new CultNetSelectionInvalidException($"{prefix}.index", field.Index, $"{prefix}.index \"{field.Index}\" is not declared by any schema this selection can reach.");
if (op.IsComparison() && declaring.Any(member => !member.IsNumeric))
throw new CultNetSelectionInvalidException($"{prefix}.index", field.Index, $"{prefix}.index \"{field.Index}\" is declared non-numeric on a schema this selection can reach; comparisons need a numeric declaration everywhere the alias is reachable.");
}
private static bool AnySchemaDeclaresRole(IReadOnlyList allDescriptors, string role) =>
allDescriptors.Any(descriptor => descriptor.DeclaredMembers.Any(member =>
member.IsReference && (member.IndexAlias ?? member.MemberName) == role));
/// The schemas reaches - every schema when absent.
public static IReadOnlyList ReachableSchemas(
CultNetSelection selection, IReadOnlyList allDescriptors)
{
if (selection.Schemas == null || selection.Schemas.Length == 0)
return allDescriptors;
return allDescriptors.Where(descriptor => CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor)).ToArray();
}
}
}