worldcut 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- worldcut/__init__.py +30 -0
- worldcut/canonicalization.py +82 -0
- worldcut/cli.py +77 -0
- worldcut/errors.py +27 -0
- worldcut/models.py +259 -0
- worldcut/planning.py +181 -0
- worldcut/py.typed +0 -0
- worldcut/requirements.py +600 -0
- worldcut/validation.py +524 -0
- worldcut/verifier.py +116 -0
- worldcut-0.1.1.dist-info/METADATA +91 -0
- worldcut-0.1.1.dist-info/RECORD +14 -0
- worldcut-0.1.1.dist-info/WHEEL +4 -0
- worldcut-0.1.1.dist-info/entry_points.txt +2 -0
worldcut/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Independent Python implementation of the WorldCut 0.1 verifier."""
|
|
2
|
+
|
|
3
|
+
from .canonicalization import canonical_json, sha256_digest
|
|
4
|
+
from .errors import WorldCutError, WorldCutErrorCode, WorldCutInputError
|
|
5
|
+
from .models import ParsedInput, VerificationResult
|
|
6
|
+
from .validation import PROTOCOL_VERSION, parse_input
|
|
7
|
+
from .verifier import (
|
|
8
|
+
CANONICALIZATION,
|
|
9
|
+
ENGINE_VERSION,
|
|
10
|
+
verify,
|
|
11
|
+
verify_json,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CANONICALIZATION",
|
|
16
|
+
"ENGINE_VERSION",
|
|
17
|
+
"PROTOCOL_VERSION",
|
|
18
|
+
"ParsedInput",
|
|
19
|
+
"VerificationResult",
|
|
20
|
+
"WorldCutError",
|
|
21
|
+
"WorldCutErrorCode",
|
|
22
|
+
"WorldCutInputError",
|
|
23
|
+
"canonical_json",
|
|
24
|
+
"parse_input",
|
|
25
|
+
"sha256_digest",
|
|
26
|
+
"verify",
|
|
27
|
+
"verify_json",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
import rfc8785
|
|
7
|
+
|
|
8
|
+
from .models import JsonValue
|
|
9
|
+
|
|
10
|
+
_MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _assert_valid_unicode(value: str, field: str) -> None:
|
|
14
|
+
for character in value:
|
|
15
|
+
code_point = ord(character)
|
|
16
|
+
if 0xD800 <= code_point <= 0xDFFF:
|
|
17
|
+
raise TypeError(f"{field} contains an unpaired surrogate")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _snapshot_json(value: object, field: str, ancestors: set[int]) -> JsonValue:
|
|
21
|
+
if value is None or isinstance(value, (str, bool)):
|
|
22
|
+
if isinstance(value, str):
|
|
23
|
+
_assert_valid_unicode(value, field)
|
|
24
|
+
return value
|
|
25
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
26
|
+
if abs(value) > _MAX_SAFE_INTEGER:
|
|
27
|
+
raise TypeError(f"{field} contains an integer outside the safe domain")
|
|
28
|
+
return value
|
|
29
|
+
if isinstance(value, float):
|
|
30
|
+
if not math.isfinite(value):
|
|
31
|
+
raise TypeError(f"{field} contains a non-finite number")
|
|
32
|
+
return value
|
|
33
|
+
if type(value) is list:
|
|
34
|
+
identity = id(value)
|
|
35
|
+
if identity in ancestors:
|
|
36
|
+
raise TypeError(f"{field} must not contain cycles")
|
|
37
|
+
ancestors.add(identity)
|
|
38
|
+
array_result = [
|
|
39
|
+
_snapshot_json(item, f"{field}[{index}]", ancestors)
|
|
40
|
+
for index, item in enumerate(value)
|
|
41
|
+
]
|
|
42
|
+
ancestors.remove(identity)
|
|
43
|
+
return array_result
|
|
44
|
+
if type(value) is dict:
|
|
45
|
+
identity = id(value)
|
|
46
|
+
if identity in ancestors:
|
|
47
|
+
raise TypeError(f"{field} must not contain cycles")
|
|
48
|
+
ancestors.add(identity)
|
|
49
|
+
object_result: dict[str, JsonValue] = {}
|
|
50
|
+
for raw_key, item in value.items():
|
|
51
|
+
if not isinstance(raw_key, str):
|
|
52
|
+
raise TypeError(f"{field} must use string object keys")
|
|
53
|
+
_assert_valid_unicode(raw_key, f"{field} key")
|
|
54
|
+
object_result[raw_key] = _snapshot_json(
|
|
55
|
+
item, f"{field}.{raw_key}", ancestors
|
|
56
|
+
)
|
|
57
|
+
ancestors.remove(identity)
|
|
58
|
+
return object_result
|
|
59
|
+
raise TypeError(f"{field} contains unsupported {type(value).__name__} data")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def canonical_json(value: JsonValue) -> str:
|
|
63
|
+
"""Return the worldcut-json-v1 canonical representation of JSON data."""
|
|
64
|
+
|
|
65
|
+
snapshot = _snapshot_json(value, "value", set())
|
|
66
|
+
try:
|
|
67
|
+
return rfc8785.dumps(snapshot).decode("utf-8")
|
|
68
|
+
except (rfc8785.CanonicalizationError, UnicodeError) as error:
|
|
69
|
+
raise TypeError(f"cannot canonicalize JSON data: {error}") from error
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sha256_digest(value: JsonValue) -> str:
|
|
73
|
+
"""Return the lowercase SHA-256 digest of canonical JSON data."""
|
|
74
|
+
|
|
75
|
+
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def utf16_sort_key(value: str) -> bytes:
|
|
79
|
+
"""Produce the protocol's raw UTF-16 code-unit ordering key."""
|
|
80
|
+
|
|
81
|
+
_assert_valid_unicode(value, "text")
|
|
82
|
+
return value.encode("utf-16-be")
|
worldcut/cli.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import WorldCutError
|
|
8
|
+
from .verifier import verify_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _usage() -> str:
|
|
12
|
+
return "\n".join(
|
|
13
|
+
[
|
|
14
|
+
"Usage: worldcut-py [--require-satisfied] <verification.json>",
|
|
15
|
+
"",
|
|
16
|
+
"Options:",
|
|
17
|
+
" --require-satisfied Exit with code 2 unless the contract is satisfied",
|
|
18
|
+
" --help Show this help",
|
|
19
|
+
]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _write_error(code: str, message: str) -> None:
|
|
24
|
+
print(
|
|
25
|
+
json.dumps({"error": {"code": code, "message": message}}),
|
|
26
|
+
file=sys.stderr,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(arguments: list[str] | None = None) -> int:
|
|
31
|
+
"""Run the WorldCut CLI and return its process exit status."""
|
|
32
|
+
|
|
33
|
+
arguments = list(sys.argv[1:] if arguments is None else arguments)
|
|
34
|
+
if "--help" in arguments:
|
|
35
|
+
print(_usage())
|
|
36
|
+
return 0
|
|
37
|
+
require_satisfied = False
|
|
38
|
+
positional: list[str] = []
|
|
39
|
+
for argument in arguments:
|
|
40
|
+
if argument == "--require-satisfied":
|
|
41
|
+
require_satisfied = True
|
|
42
|
+
elif argument.startswith("-"):
|
|
43
|
+
_write_error("WORLDCUT_INVALID_ARGUMENT", f"Unknown option: {argument}")
|
|
44
|
+
return 1
|
|
45
|
+
else:
|
|
46
|
+
positional.append(argument)
|
|
47
|
+
if len(positional) != 1:
|
|
48
|
+
_write_error(
|
|
49
|
+
"WORLDCUT_INVALID_ARGUMENT",
|
|
50
|
+
"Exactly one verification JSON file is required",
|
|
51
|
+
)
|
|
52
|
+
return 1
|
|
53
|
+
path = Path(positional[0]).resolve()
|
|
54
|
+
try:
|
|
55
|
+
source = path.read_bytes()
|
|
56
|
+
except OSError:
|
|
57
|
+
_write_error("WORLDCUT_FILE_READ_FAILED", f"Unable to read {path}")
|
|
58
|
+
return 1
|
|
59
|
+
try:
|
|
60
|
+
result = verify_json(source)
|
|
61
|
+
except WorldCutError as error:
|
|
62
|
+
_write_error(error.code, str(error))
|
|
63
|
+
return 1
|
|
64
|
+
print(json.dumps(result, indent=2))
|
|
65
|
+
if require_satisfied and result["verdict"] != "CONTRACT_SATISFIED":
|
|
66
|
+
return 2
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
"""Console-script entry point."""
|
|
72
|
+
|
|
73
|
+
raise SystemExit(run())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|
worldcut/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal, TypeAlias
|
|
4
|
+
|
|
5
|
+
WorldCutErrorCode: TypeAlias = Literal[
|
|
6
|
+
"WORLDCUT_INVALID_INPUT",
|
|
7
|
+
"WORLDCUT_INVALID_ARGUMENT",
|
|
8
|
+
"WORLDCUT_FILE_READ_FAILED",
|
|
9
|
+
"WORLDCUT_RUNTIME_ERROR",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WorldCutError(Exception):
|
|
14
|
+
"""Base exception carrying a stable WorldCut error code."""
|
|
15
|
+
|
|
16
|
+
code: WorldCutErrorCode
|
|
17
|
+
|
|
18
|
+
def __init__(self, code: WorldCutErrorCode, message: str) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.code = code
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WorldCutInputError(WorldCutError):
|
|
24
|
+
"""Raised when transport data violates the WorldCut protocol."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str) -> None:
|
|
27
|
+
super().__init__("WORLDCUT_INVALID_INPUT", message)
|
worldcut/models.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Literal, NotRequired, TypeAlias, TypedDict
|
|
7
|
+
|
|
8
|
+
JsonPrimitive: TypeAlias = str | int | float | bool | None
|
|
9
|
+
JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]
|
|
10
|
+
FrozenJson: TypeAlias = (
|
|
11
|
+
JsonPrimitive | tuple["FrozenJson", ...] | Mapping[str, "FrozenJson"]
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
Provenance: TypeAlias = Literal[
|
|
15
|
+
"provider_asserted",
|
|
16
|
+
"client_observed",
|
|
17
|
+
"derived",
|
|
18
|
+
"operator_supplied",
|
|
19
|
+
]
|
|
20
|
+
RequirementStatus: TypeAlias = Literal["SATISFIED", "VIOLATED", "UNKNOWN"]
|
|
21
|
+
ContractVerdict: TypeAlias = Literal[
|
|
22
|
+
"CONTRACT_SATISFIED",
|
|
23
|
+
"CONTRACT_VIOLATED",
|
|
24
|
+
"INSUFFICIENT_EVIDENCE",
|
|
25
|
+
]
|
|
26
|
+
RequirementType: TypeAlias = Literal["dependency", "common_valid_time", "value_equals"]
|
|
27
|
+
AcquisitionActionType: TypeAlias = Literal[
|
|
28
|
+
"REFRESH_OBSERVATION",
|
|
29
|
+
"FETCH_REQUIRED_METADATA",
|
|
30
|
+
"ACQUIRE_COMPATIBLE_EVIDENCE",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AcquisitionAction(TypedDict):
|
|
35
|
+
id: str
|
|
36
|
+
type: AcquisitionActionType
|
|
37
|
+
role: str
|
|
38
|
+
cost: int
|
|
39
|
+
description: str
|
|
40
|
+
expected: JsonValue
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AcquisitionOption(TypedDict):
|
|
44
|
+
id: str
|
|
45
|
+
description: str
|
|
46
|
+
actions: list[AcquisitionAction]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RequirementResult(TypedDict):
|
|
50
|
+
requirementId: str
|
|
51
|
+
requirementType: RequirementType
|
|
52
|
+
required: bool
|
|
53
|
+
status: RequirementStatus
|
|
54
|
+
summary: str
|
|
55
|
+
details: JsonValue
|
|
56
|
+
acquisitionOptions: list[AcquisitionOption]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AcquisitionPlan(TypedDict):
|
|
60
|
+
status: Literal["NOT_NEEDED", "AVAILABLE", "INCOMPLETE"]
|
|
61
|
+
reason: str | None
|
|
62
|
+
actions: list[AcquisitionAction]
|
|
63
|
+
selectedOptionIds: list[str]
|
|
64
|
+
totalCost: int
|
|
65
|
+
coveredRequirementIds: list[str]
|
|
66
|
+
unresolvedRequirementIds: list[str]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class VerificationCoverage(TypedDict):
|
|
70
|
+
required: int
|
|
71
|
+
satisfied: int
|
|
72
|
+
violated: int
|
|
73
|
+
unknown: int
|
|
74
|
+
advisory: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class VerificationResult(TypedDict):
|
|
78
|
+
protocolVersion: Literal["0.1"]
|
|
79
|
+
engineVersion: str
|
|
80
|
+
canonicalization: Literal["worldcut-json-v1"]
|
|
81
|
+
contractId: str
|
|
82
|
+
contractVersion: str
|
|
83
|
+
verdict: ContractVerdict
|
|
84
|
+
coverage: VerificationCoverage
|
|
85
|
+
requirementResults: list[RequirementResult]
|
|
86
|
+
acquisitionPlan: AcquisitionPlan
|
|
87
|
+
verificationRecordDigest: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ResourceInput(TypedDict):
|
|
91
|
+
provider: str
|
|
92
|
+
account: str
|
|
93
|
+
kind: str
|
|
94
|
+
key: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ValidityInput(TypedDict):
|
|
98
|
+
from_: str
|
|
99
|
+
until: str | None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DependencyInput(TypedDict):
|
|
103
|
+
name: str
|
|
104
|
+
resource: ResourceInput
|
|
105
|
+
relation: Literal["exact"]
|
|
106
|
+
provenance: Provenance
|
|
107
|
+
version: NotRequired[str]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class ResourceIdentity:
|
|
112
|
+
provider: str
|
|
113
|
+
account: str
|
|
114
|
+
kind: str
|
|
115
|
+
key: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
119
|
+
class NormalizedTimestamp:
|
|
120
|
+
order: int
|
|
121
|
+
text: str = field(compare=False)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class ValidityInterval:
|
|
126
|
+
from_text: str
|
|
127
|
+
until_text: str | None
|
|
128
|
+
start: NormalizedTimestamp
|
|
129
|
+
end: NormalizedTimestamp | None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True)
|
|
133
|
+
class DependencyWitness:
|
|
134
|
+
name: str
|
|
135
|
+
resource: ResourceIdentity
|
|
136
|
+
relation: Literal["exact"]
|
|
137
|
+
version: str | None
|
|
138
|
+
provenance: Provenance
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class ObservationWitness:
|
|
143
|
+
provenance: Provenance
|
|
144
|
+
version: str | None
|
|
145
|
+
validity: ValidityInterval | None
|
|
146
|
+
dependencies: tuple[DependencyWitness, ...]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True, slots=True)
|
|
150
|
+
class Observation:
|
|
151
|
+
id: str
|
|
152
|
+
role: str
|
|
153
|
+
resource: ResourceIdentity
|
|
154
|
+
value: FrozenJson
|
|
155
|
+
observed_at_text: str
|
|
156
|
+
observed_at: NormalizedTimestamp
|
|
157
|
+
acquisition_cost: int
|
|
158
|
+
witness: ObservationWitness
|
|
159
|
+
raw: Mapping[str, FrozenJson]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class RequirementBase:
|
|
164
|
+
id: str
|
|
165
|
+
description: str
|
|
166
|
+
required: bool
|
|
167
|
+
raw: Mapping[str, FrozenJson]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class DependencyRequirement(RequirementBase):
|
|
172
|
+
type: Literal["dependency"]
|
|
173
|
+
dependent_role: str
|
|
174
|
+
target_role: str
|
|
175
|
+
dependency_name: str
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True, slots=True)
|
|
179
|
+
class CommonValidTimeRequirement(RequirementBase):
|
|
180
|
+
type: Literal["common_valid_time"]
|
|
181
|
+
roles: tuple[str, ...]
|
|
182
|
+
within: ValidityInterval
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class ValueEqualsRequirement(RequirementBase):
|
|
187
|
+
type: Literal["value_equals"]
|
|
188
|
+
role: str
|
|
189
|
+
path: tuple[str, ...]
|
|
190
|
+
expected: FrozenJson
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
Requirement: TypeAlias = (
|
|
194
|
+
DependencyRequirement | CommonValidTimeRequirement | ValueEqualsRequirement
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True, slots=True)
|
|
199
|
+
class Contract:
|
|
200
|
+
id: str
|
|
201
|
+
version: str
|
|
202
|
+
decision_time_text: str
|
|
203
|
+
decision_time: NormalizedTimestamp
|
|
204
|
+
requirements: tuple[Requirement, ...]
|
|
205
|
+
raw: Mapping[str, FrozenJson]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True, slots=True)
|
|
209
|
+
class _VerificationInput:
|
|
210
|
+
protocol_version: Literal["0.1"]
|
|
211
|
+
contract: Contract
|
|
212
|
+
observations: tuple[Observation, ...]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_PARSED_INPUT_TOKEN = object()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ParsedInput:
|
|
219
|
+
"""Opaque immutable handle returned by :func:`worldcut.parse_input`."""
|
|
220
|
+
|
|
221
|
+
__slots__ = ("__value",)
|
|
222
|
+
__value: _VerificationInput
|
|
223
|
+
|
|
224
|
+
def __init__(self, value: _VerificationInput, token: object) -> None:
|
|
225
|
+
if token is not _PARSED_INPUT_TOKEN:
|
|
226
|
+
raise TypeError("ParsedInput must be produced by parse_input")
|
|
227
|
+
object.__setattr__(self, "_ParsedInput__value", value)
|
|
228
|
+
|
|
229
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
230
|
+
raise AttributeError("ParsedInput is immutable")
|
|
231
|
+
|
|
232
|
+
def _unwrap(self) -> _VerificationInput:
|
|
233
|
+
return self.__value
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _new_parsed_input(value: _VerificationInput) -> ParsedInput:
|
|
237
|
+
return ParsedInput(value, _PARSED_INPUT_TOKEN)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _unwrap_parsed_input(value: ParsedInput) -> _VerificationInput:
|
|
241
|
+
if not isinstance(value, ParsedInput):
|
|
242
|
+
raise TypeError("verification input must be produced by parse_input")
|
|
243
|
+
return value._unwrap()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def freeze_json(value: JsonValue) -> FrozenJson:
|
|
247
|
+
if isinstance(value, dict):
|
|
248
|
+
return MappingProxyType({key: freeze_json(item) for key, item in value.items()})
|
|
249
|
+
if isinstance(value, list):
|
|
250
|
+
return tuple(freeze_json(item) for item in value)
|
|
251
|
+
return value
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def thaw_json(value: FrozenJson) -> JsonValue:
|
|
255
|
+
if isinstance(value, Mapping):
|
|
256
|
+
return {key: thaw_json(item) for key, item in value.items()}
|
|
257
|
+
if isinstance(value, tuple):
|
|
258
|
+
return [thaw_json(item) for item in value]
|
|
259
|
+
return value
|
worldcut/planning.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .canonicalization import utf16_sort_key
|
|
6
|
+
from .errors import WorldCutInputError
|
|
7
|
+
from .models import AcquisitionAction, AcquisitionPlan, RequirementResult
|
|
8
|
+
|
|
9
|
+
MAX_UNRESOLVED_REQUIREMENTS = 64
|
|
10
|
+
MAX_OPTION_COMBINATIONS = 65_536
|
|
11
|
+
MAX_SEARCH_STATES = (MAX_UNRESOLVED_REQUIREMENTS + 1) * MAX_OPTION_COMBINATIONS
|
|
12
|
+
MAX_ACQUISITION_COST = 1_000_000_000
|
|
13
|
+
MAX_PLAN_TOTAL_COST = 64_000_000_000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class _SearchState:
|
|
18
|
+
index: int
|
|
19
|
+
option_ids: list[str]
|
|
20
|
+
actions: dict[str, AcquisitionAction]
|
|
21
|
+
cost: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class _Candidate:
|
|
26
|
+
option_ids: list[str]
|
|
27
|
+
actions: list[AcquisitionAction]
|
|
28
|
+
cost: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _candidate_key(candidate: _Candidate) -> tuple[int, int, bytes]:
|
|
32
|
+
return (
|
|
33
|
+
candidate.cost,
|
|
34
|
+
len(candidate.actions),
|
|
35
|
+
utf16_sort_key("\0".join(candidate.option_ids)),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _incomplete(reason: str, unresolved: list[RequirementResult]) -> AcquisitionPlan:
|
|
40
|
+
return {
|
|
41
|
+
"status": "INCOMPLETE",
|
|
42
|
+
"reason": reason,
|
|
43
|
+
"actions": [],
|
|
44
|
+
"selectedOptionIds": [],
|
|
45
|
+
"totalCost": 0,
|
|
46
|
+
"coveredRequirementIds": [],
|
|
47
|
+
"unresolvedRequirementIds": [result["requirementId"] for result in unresolved],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _add_option(
|
|
52
|
+
state: _SearchState, option_id: str, option_actions: list[AcquisitionAction]
|
|
53
|
+
) -> _SearchState:
|
|
54
|
+
actions = dict(state.actions)
|
|
55
|
+
cost = state.cost
|
|
56
|
+
for action in option_actions:
|
|
57
|
+
action_cost = action["cost"]
|
|
58
|
+
if action_cost < 0 or action_cost > MAX_ACQUISITION_COST:
|
|
59
|
+
raise WorldCutInputError(
|
|
60
|
+
f"Acquisition action {action['id']} cost must be between 0 and "
|
|
61
|
+
f"{MAX_ACQUISITION_COST}"
|
|
62
|
+
)
|
|
63
|
+
existing = actions.get(action["id"])
|
|
64
|
+
if existing is not None:
|
|
65
|
+
if existing["cost"] != action_cost:
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
f"Acquisition action {action['id']} has conflicting declared costs"
|
|
68
|
+
)
|
|
69
|
+
continue
|
|
70
|
+
if cost > MAX_PLAN_TOTAL_COST - action_cost:
|
|
71
|
+
raise WorldCutInputError(
|
|
72
|
+
f"Acquisition plan cost exceeds {MAX_PLAN_TOTAL_COST}"
|
|
73
|
+
)
|
|
74
|
+
actions[action["id"]] = action
|
|
75
|
+
cost += action_cost
|
|
76
|
+
return _SearchState(
|
|
77
|
+
index=state.index + 1,
|
|
78
|
+
option_ids=[*state.option_ids, option_id],
|
|
79
|
+
actions=actions,
|
|
80
|
+
cost=cost,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def select_acquisition_plan(
|
|
85
|
+
requirement_results: list[RequirementResult],
|
|
86
|
+
) -> AcquisitionPlan:
|
|
87
|
+
"""Select the exact minimum-cost acquisition plan within protocol limits."""
|
|
88
|
+
|
|
89
|
+
unresolved = sorted(
|
|
90
|
+
(
|
|
91
|
+
result
|
|
92
|
+
for result in requirement_results
|
|
93
|
+
if result["required"] and result["status"] != "SATISFIED"
|
|
94
|
+
),
|
|
95
|
+
key=lambda result: utf16_sort_key(result["requirementId"]),
|
|
96
|
+
)
|
|
97
|
+
if not unresolved:
|
|
98
|
+
return {
|
|
99
|
+
"status": "NOT_NEEDED",
|
|
100
|
+
"reason": None,
|
|
101
|
+
"actions": [],
|
|
102
|
+
"selectedOptionIds": [],
|
|
103
|
+
"totalCost": 0,
|
|
104
|
+
"coveredRequirementIds": [],
|
|
105
|
+
"unresolvedRequirementIds": [],
|
|
106
|
+
}
|
|
107
|
+
if len(unresolved) > MAX_UNRESOLVED_REQUIREMENTS:
|
|
108
|
+
return _incomplete(
|
|
109
|
+
"Acquisition planning supports at most 64 unresolved requirements.",
|
|
110
|
+
unresolved,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
coverable: list[RequirementResult] = []
|
|
114
|
+
impossible: list[str] = []
|
|
115
|
+
combinations = 1
|
|
116
|
+
for result in unresolved:
|
|
117
|
+
options = result["acquisitionOptions"]
|
|
118
|
+
if not options:
|
|
119
|
+
impossible.append(result["requirementId"])
|
|
120
|
+
continue
|
|
121
|
+
if combinations > MAX_OPTION_COMBINATIONS // len(options):
|
|
122
|
+
return _incomplete(
|
|
123
|
+
"Acquisition search exceeds the 65536 combination limit.",
|
|
124
|
+
unresolved,
|
|
125
|
+
)
|
|
126
|
+
combinations *= len(options)
|
|
127
|
+
coverable.append(result)
|
|
128
|
+
|
|
129
|
+
stack = [_SearchState(index=0, option_ids=[], actions={}, cost=0)]
|
|
130
|
+
best: _Candidate | None = None
|
|
131
|
+
visited = 0
|
|
132
|
+
while stack:
|
|
133
|
+
visited += 1
|
|
134
|
+
if visited > MAX_SEARCH_STATES:
|
|
135
|
+
return _incomplete(
|
|
136
|
+
"Acquisition search exceeds the 4259840 state limit.",
|
|
137
|
+
unresolved,
|
|
138
|
+
)
|
|
139
|
+
state = stack.pop()
|
|
140
|
+
if best is not None and state.cost > best.cost:
|
|
141
|
+
continue
|
|
142
|
+
if state.index >= len(coverable):
|
|
143
|
+
candidate = _Candidate(
|
|
144
|
+
option_ids=sorted(state.option_ids, key=utf16_sort_key),
|
|
145
|
+
actions=sorted(
|
|
146
|
+
state.actions.values(),
|
|
147
|
+
key=lambda action: utf16_sort_key(action["id"]),
|
|
148
|
+
),
|
|
149
|
+
cost=state.cost,
|
|
150
|
+
)
|
|
151
|
+
if best is None or _candidate_key(candidate) < _candidate_key(best):
|
|
152
|
+
best = candidate
|
|
153
|
+
continue
|
|
154
|
+
options = sorted(
|
|
155
|
+
coverable[state.index]["acquisitionOptions"],
|
|
156
|
+
key=lambda option: utf16_sort_key(option["id"]),
|
|
157
|
+
)
|
|
158
|
+
for option in reversed(options):
|
|
159
|
+
next_state = _add_option(state, option["id"], option["actions"])
|
|
160
|
+
if best is None or next_state.cost <= best.cost:
|
|
161
|
+
stack.append(next_state)
|
|
162
|
+
|
|
163
|
+
if best is None:
|
|
164
|
+
return _incomplete(
|
|
165
|
+
"Acquisition search completed without a valid option set.",
|
|
166
|
+
unresolved,
|
|
167
|
+
)
|
|
168
|
+
impossible.sort(key=utf16_sort_key)
|
|
169
|
+
return {
|
|
170
|
+
"status": "AVAILABLE" if not impossible else "INCOMPLETE",
|
|
171
|
+
"reason": (
|
|
172
|
+
None
|
|
173
|
+
if not impossible
|
|
174
|
+
else (f"No acquisition option is available for: {', '.join(impossible)}.")
|
|
175
|
+
),
|
|
176
|
+
"actions": best.actions,
|
|
177
|
+
"selectedOptionIds": best.option_ids,
|
|
178
|
+
"totalCost": best.cost,
|
|
179
|
+
"coveredRequirementIds": [result["requirementId"] for result in coverable],
|
|
180
|
+
"unresolvedRequirementIds": impossible,
|
|
181
|
+
}
|
worldcut/py.typed
ADDED
|
File without changes
|