223 lines
11 KiB
GDScript
223 lines
11 KiB
GDScript
extends SceneTree
|
|
|
|
const Digest = preload("res://scripts/core/simulation_digest.gd")
|
|
const Reference = preload("res://tests/performance/fixtures/simulation_digest_v1_reference.gd")
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("Simulation digest: v1 byte compatibility and growing-history benchmark")
|
|
_test_value_types()
|
|
_test_dictionary_layouts()
|
|
_test_mutable_values()
|
|
_test_limits_and_rejections()
|
|
_test_growing_histories()
|
|
print("Digest performance: %d checks, %d failures" % [_checks, _failures])
|
|
quit(_failures)
|
|
|
|
|
|
func _test_value_types() -> void:
|
|
var values: Array = [
|
|
null, false, true, -9223372036854775807, 9223372036854775807,
|
|
0, 0.0, -0.0, -0.125, 1.25e100, "", "厨房\u0000🌳", &"", &"運搬",
|
|
Vector2(-1.5, 2.25), Vector2i(-3, 7), Rect2(-1.5, 2.25, 3.5, 4.75),
|
|
Rect2i(-3, 7, 2, 8), Vector3(-1.5, 2.25, 3.5), Vector3i(-3, 7, 2),
|
|
Vector4(-1.5, 2.25, 3.5, 4.75), Vector4i(-3, 7, 2, 8), Color(0.25, 0.5, 0.75, 1),
|
|
[], {}, PackedByteArray([0, 127, 255]), PackedInt32Array([-3, 0, 7]),
|
|
PackedInt64Array([-9223372036854775807, 9223372036854775807]),
|
|
PackedFloat32Array([-1.25, 0.0, 2.5]), PackedFloat64Array([-1.25, -0.0, 1.25e100]),
|
|
PackedStringArray(["", "日本語", "🌳"]), PackedVector2Array([Vector2(-1, 2)]),
|
|
PackedVector3Array([Vector3(-1, 2, 3)]), PackedColorArray([Color(0.25, 0.5, 0.75, 1)]),
|
|
]
|
|
var strings: Array[String] = ["", "a", "厨房"]
|
|
var names: Array[StringName] = [&"", &"a", &"厨房"]
|
|
values.append(strings)
|
|
values.append(names)
|
|
for index in values.size():
|
|
_expect_bytes(values[index], "canonical bytes for value type %d, sample %d" % [typeof(values[index]), index])
|
|
_expect_bytes({"values": values}, "nested complete supported value matrix")
|
|
_expect(Digest.compute([1, 2, 3]) == "4e32bb2921e1b879d5b8778789d3bc7e46559d4448e95f9c2fed427d7e5c7b93", "published v1 reference digest remains unchanged")
|
|
|
|
|
|
func _test_dictionary_layouts() -> void:
|
|
var rows: Array = []
|
|
# Native Variant equality can equate String and StringName; cache buckets must
|
|
# keep their type tags apart even when the textual schemas are identical.
|
|
for index in 60:
|
|
var row := {}
|
|
var keys: Array = ["z", "aa", "日本語", "a", "\u0000", "", "001"]
|
|
if index % 2 == 1:
|
|
keys.reverse()
|
|
for key: String in keys:
|
|
row[StringName(key) if index % 3 == 0 else key] = {"index": index, "key": key}
|
|
rows.append(row)
|
|
_expect_bytes(rows, "cached String and StringName schemas preserve tags and byte order")
|
|
var mixed := {}
|
|
mixed["text"] = "String"
|
|
mixed[&"name"] = "StringName"
|
|
mixed[7] = "integer"
|
|
mixed[1.25] = "float"
|
|
mixed[Vector2i(2, 3)] = "vector"
|
|
mixed[["a", 1]] = "array key"
|
|
mixed[{"inner": "key"}] = "dictionary key"
|
|
_expect_bytes([mixed, mixed.duplicate(true)], "mixed and mutable dictionary keys use the uncached encoder")
|
|
var forward := {"a": 1, "b": 2, "ccc": 3}
|
|
var reverse := {"ccc": 3, "b": 2, "a": 1}
|
|
_expect(Digest.canonical_bytes(forward) == Digest.canonical_bytes(reverse), "canonical order ignores dictionary insertion order")
|
|
# Exceed each bound to check graceful uncached fallback, without relying on a
|
|
# process-global cache or on the hash of an incomplete key signature.
|
|
var many_schemas: Array = []
|
|
for index in 600:
|
|
many_schemas.append({"schema_%d" % index: index, "nested": {"value": index}})
|
|
_expect_bytes(many_schemas, "more than the bounded layout capacity remains byte-identical")
|
|
# These two complete key arrays collide under Godot 4.7's native hash. The
|
|
# cache must still compare complete contents, never index by hash alone.
|
|
var collision_left := {"schema.96ac180f": 1, "value": 2}
|
|
var collision_right := {"schema.c5015a12": 3, "value": 4}
|
|
if hash(collision_left.keys()) == hash(collision_right.keys()):
|
|
print(" Native layout hash-collision fixture exercised")
|
|
_expect_bytes([collision_left, collision_right, collision_left], "different key layouts with colliding native hashes remain distinct")
|
|
var many_keys := {}
|
|
for index in 80:
|
|
many_keys["key_%d" % index] = index
|
|
_expect_bytes(many_keys, "large dictionaries remain byte-identical")
|
|
_expect_bytes({"長".repeat(200): 1}, "long keys bypass the cache")
|
|
|
|
|
|
func _test_mutable_values() -> void:
|
|
var nested := {"history": [{"minute": 1, "amount": 3}], "queue": [1, 2]}
|
|
var before := Digest.compute(nested)
|
|
nested["history"][0]["amount"] = 4
|
|
nested["queue"].append(3)
|
|
_expect_bytes(nested, "nested value mutations cannot reuse stale encoded values")
|
|
_expect(Digest.compute(nested) != before, "nested mutation changes the hash")
|
|
nested.erase("queue")
|
|
nested[&"queue"] = [1, 2]
|
|
_expect_bytes(nested, "key type mutation between digest calls is observed")
|
|
var left := {"key": {"value": 1}}
|
|
var right := {"key": {"value": 2}}
|
|
_expect_bytes([left, right], "reused schema reads each dictionary's current values")
|
|
var canonical := Digest.canonical_bytes(nested)
|
|
canonical.fill(0)
|
|
_expect_bytes(nested, "caller mutation of previous output cannot corrupt another call")
|
|
|
|
|
|
func _test_limits_and_rejections() -> void:
|
|
var typed_leaf: Array[String] = ["leaf"]
|
|
_expect_bytes(_nested(typed_leaf, 127), "typed string item at exact nesting limit is valid")
|
|
_expect_bytes(_nested({"leaf": 1}, 127), "cached dictionary key at exact nesting limit is valid")
|
|
_expect_bytes(_nested([], 128), "empty container at nesting limit is valid")
|
|
var rejected: Array = [_nested(typed_leaf, 128), _nested({"leaf": 1}, 128), _nested(null, 129), NAN, INF, -INF, Vector2(NAN, 1), PackedFloat64Array([INF]), {"nested": NAN}, NodePath("runtime/object")]
|
|
var object := RefCounted.new()
|
|
rejected.append(object)
|
|
var errors_enabled := Engine.print_error_messages
|
|
Engine.print_error_messages = false
|
|
for index in rejected.size():
|
|
var legacy: PackedByteArray = Reference.canonical_bytes(rejected[index])
|
|
var current: PackedByteArray = Digest.canonical_bytes(rejected[index])
|
|
_expect(legacy.is_empty() and current.is_empty(), "unsupported or over-depth sample %d still rejects" % index)
|
|
Engine.print_error_messages = errors_enabled
|
|
|
|
|
|
func _test_growing_histories() -> void:
|
|
var reports: Array[Dictionary] = []
|
|
for history_count in [100, 1000, 5000]:
|
|
var snapshot := _history_snapshot(history_count)
|
|
_expect_bytes(snapshot, "growing-history snapshot with %d records" % history_count)
|
|
_expect(Digest.compute(snapshot) == Reference.compute(snapshot), "growing-history SHA-256 with %d records" % history_count)
|
|
# Warm both encoders, then alternate execution order. Timing is reported,
|
|
# never asserted: CPU load and editor/preview activity vary across machines.
|
|
Reference.compute(snapshot)
|
|
Digest.compute(snapshot)
|
|
var reference_samples: Array[float] = []
|
|
var optimized_samples: Array[float] = []
|
|
for sample in 5:
|
|
if sample % 2 == 0:
|
|
reference_samples.append(_measure(Reference.compute, snapshot))
|
|
optimized_samples.append(_measure(Digest.compute, snapshot))
|
|
else:
|
|
optimized_samples.append(_measure(Digest.compute, snapshot))
|
|
reference_samples.append(_measure(Reference.compute, snapshot))
|
|
reference_samples.sort()
|
|
optimized_samples.sort()
|
|
var reference_ms: float = reference_samples[2]
|
|
var optimized_ms: float = optimized_samples[2]
|
|
var report := {
|
|
"history_records": history_count,
|
|
"canonical_bytes": Digest.canonical_bytes(snapshot).size(),
|
|
"baseline_median_ms": snappedf(reference_ms, 0.001),
|
|
"optimized_median_ms": snappedf(optimized_ms, 0.001),
|
|
"speedup": snappedf(reference_ms / optimized_ms, 0.001),
|
|
}
|
|
reports.append(report)
|
|
print("DIGEST_BENCHMARK %s" % JSON.stringify(report))
|
|
print("DIGEST_PERFORMANCE_REPORT %s" % JSON.stringify({"samples_per_encoder": 5, "metric": "median wall time including SHA-256", "fixture": "synthetic event, ledger, and service histories; complete histories retained", "results": reports}))
|
|
var ids: Array[String] = []
|
|
for index in 10000:
|
|
ids.append("facility.operation.finished:%08d:reception.1:guest.%08d" % [index, index / 4])
|
|
var id_snapshot := {"version": 1, "published_ids": ids, "recent_events": []}
|
|
_expect_bytes(id_snapshot, "append-only published event ID history remains complete")
|
|
var id_reference_samples: Array[float] = []
|
|
var id_optimized_samples: Array[float] = []
|
|
for sample in 5:
|
|
id_reference_samples.append(_measure(Reference.compute, id_snapshot))
|
|
id_optimized_samples.append(_measure(Digest.compute, id_snapshot))
|
|
id_reference_samples.sort()
|
|
id_optimized_samples.sort()
|
|
print("DIGEST_EVENT_HISTORY_BENCHMARK %s" % JSON.stringify({"published_ids": ids.size(), "canonical_bytes": Digest.canonical_bytes(id_snapshot).size(), "baseline_median_ms": snappedf(id_reference_samples[2], 0.001), "optimized_median_ms": snappedf(id_optimized_samples[2], 0.001), "speedup": snappedf(id_reference_samples[2] / id_optimized_samples[2], 0.001)}))
|
|
|
|
|
|
func _history_snapshot(count: int) -> Dictionary:
|
|
var published_ids: Array[String] = []
|
|
var recent_events: Array[Dictionary] = []
|
|
var entries: Array[Dictionary] = []
|
|
var receipts: Array[Dictionary] = []
|
|
for index in count:
|
|
var event_id := "event.economy.ledger.%08d" % index
|
|
published_ids.append(event_id)
|
|
entries.append({
|
|
"entry_id": "entry.%08d" % index, "absolute_minute": index * 3,
|
|
"entry_type": "operating_expense" if index % 3 == 0 else "service_revenue",
|
|
"amount": -12 if index % 3 == 0 else 35, "account_id": "cash",
|
|
"counterparty_id": "guest.%05d" % (index % 250), "reference_id": event_id,
|
|
"metadata": {"facility_id": "cafeteria.1", "quantity": 1, "description": "基本の食事"},
|
|
})
|
|
receipts.append({"receipt_id": "receipt.%08d" % index, "minute": index * 3, "guest_id": "guest.%05d" % (index % 250), "facility_id": "cafeteria.1", "satisfied": true, "price": 35})
|
|
if index >= maxi(count - 128, 0):
|
|
recent_events.append({"event_id": event_id, "event_type": "ledger.posted", "absolute_minute": index * 3, "source_service": "economy", "entity_ids": ["cafeteria.1"], "payload": {"amount": 35, "quantity": 1}, "causation_command_id": index})
|
|
return {"version": 7, "minute": count * 3, "domain_events": {"version": 1, "published_ids": published_ids, "recent_events": recent_events}, "economy": {"version": 1, "balance": 5000 + count * 12, "entries": entries}, "operations": {"version": 1, "receipts": receipts}}
|
|
|
|
|
|
func _nested(leaf: Variant, levels: int) -> Variant:
|
|
var result: Variant = leaf
|
|
for index in levels:
|
|
result = [result]
|
|
return result
|
|
|
|
|
|
func _measure(encoder: Callable, snapshot: Dictionary) -> float:
|
|
var started := Time.get_ticks_usec()
|
|
encoder.call(snapshot)
|
|
return float(Time.get_ticks_usec() - started) / 1000.0
|
|
|
|
|
|
func _expect_bytes(value: Variant, label: String) -> void:
|
|
var expected: PackedByteArray = Reference.canonical_bytes(value)
|
|
var actual: PackedByteArray = Digest.canonical_bytes(value)
|
|
_expect(not expected.is_empty() and actual == expected, label)
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" PASS: %s" % label)
|
|
else:
|
|
_failures += 1
|
|
push_error(" FAIL: %s" % label)
|