Documentation
¶
Overview ¶
Package pyfmt reproduces the CPython value-formatting primitives that SEC-AF's Python source leaks into observable output: round(), str(float), str(x) and repr(x).
Why a package for this: SEC-AF's prompts, notes and error messages are built with f-strings that interpolate Python values directly (`f"Default candidates: {default_candidates}"` renders a Python list repr, `f"{name} failed: {message}"` renders str() of whatever the control plane returned), and its scoring/aggregation code calls round(x, n). Go's stdlib formats all of those differently, so a naive port would silently change the bytes that reach the LLM and the strings that reach the user.
Nothing here reads the environment or has state; every function is pure and deterministic. Ground truth for every table-driven case in pyfmt_test.go was produced by running the quoted one-liner under ~/.agentfield/packages/sec-af/venv/bin/python (CPython 3.11.12).
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Dumps ¶
Dumps reproduces CPython's json.dumps(v, indent=indent) applied to the dict a pydantic model_dump() produces (DESIGN.md §2b).
SEC-AF embeds json.dumps output directly in prompt text, in checkpoint files and in output artifacts:
src/sec_af/agents/recon/architecture.py:43 json.dumps(architecture.model_dump(), indent=2) src/sec_af/agents/hunt/ssrf.py:36 json.dumps(context, indent=2) src/sec_af/orchestrator.py:477 json.dumps(body, indent=2) src/sec_af/harness.py:161 json.dumps(schema_json, indent=2)
so the bytes matter. Go's encoding/json differs from CPython's json module in four ways that all show up in that text:
- FLOATS. Go renders a float64 with the shortest round-tripping 'g'-ish form ("1", "1e+15"); Python renders repr(float) ("1.0", "1000000000000000.0"). Every float here goes through FormatFloat.
- NON-ASCII. Python defaults to ensure_ascii=True, so an accented letter becomes a \uXXXX escape and an astral character becomes a UTF-16 surrogate pair. Go emits raw UTF-8.
- HTML CHARACTERS. Go escapes the three characters < > & as \u003c, \u003e and \u0026 by default (SetEscapeHTML). Python escapes none of them.
- SEPARATORS. Python's compact form uses ", " and ": " (json.dumps' default separators when indent is None); Go's Marshal uses "," and ":".
Indent semantics: indent > 0 renders the multi-line form with that many spaces per level and the (",", ": ") separators Python switches to whenever indent is not None. indent <= 0 renders the single-line form with Python's default (", ", ": ") separators — i.e. Dumps(v, 0) == DumpsCompact(v). That is a deliberate simplification: Python's literal indent=0 emits newlines with zero-width indentation, a spelling no SEC-AF call site uses (every call is either json.dumps(x) or json.dumps(x, indent=2)).
Value mapping:
nil, nil pointer/interface/map/slice -> null
bool -> true / false
int/uint kinds -> decimal integer
float32/float64 -> FormatFloat (NaN/±Inf ->
NaN/Infinity/-Infinity, exactly
what Python emits with the default
allow_nan=True)
json.Number -> verbatim when integral, else
FormatFloat of its value
string -> ensure_ascii-escaped JSON string
[]byte -> base64 string (encoding/json
parity; pydantic has no bytes field)
slice/array -> array
Ordered / KV -> object in INSERTION order
map -> object with SORTED keys
struct -> object, fields in DECLARATION
order, json tags honored
json.Marshaler -> its MarshalJSON output, re-rendered
through this same encoder
Two deliberate deviations, both documented in DESIGN.md §2b:
- MAP KEY ORDER. A Python dict preserves insertion order and json.dumps renders it in that order; a Go map carries no order at all. Dumps sorts map keys so the output is deterministic and diffable. A call site whose bytes must match Python exactly — e.g. hunt/ssrf.py's `{"app_type": ..., "auth_model": ..., "frameworks": ...}`, whose insertion order is NOT alphabetical — must build a pyfmt.Ordered instead of a map. Structs need no such care: Go declaration order is pydantic declaration order, which is model_dump()'s insertion order.
- NIL SLICES AND MAPS render as null, matching encoding/json. Pydantic never produces None for a list field, and the port's schema structs seed `[]` defaults in their UnmarshalJSON, so a nil only appears where a Go caller left a struct at its zero value — in which case null is the honest rendering of "this was never populated".
func DumpsCompact ¶
DumpsCompact reproduces json.dumps(v) with no indent — Python's default separators, ", " between items and ": " between key and value.
func DumpsModelJSON ¶
DumpsModelJSON reproduces pydantic v2's `BaseModel.model_dump_json()`, which is NOT `json.dumps(model.model_dump())` and therefore cannot go through Dumps.
SEC-AF puts the bytes on the wire in two places:
src/sec_af/orchestrator.py:652 self.app.note(progress.model_dump_json(), tags=[...]) src/sec_af/output/json_output.py:12 full_json = result.model_dump_json()
(the second already has a package-local writer in internal/output; this is the shared one the orchestrator's progress note needs).
pydantic serializes through serde_json rather than CPython's json module, so it differs from Dumps in exactly three ways — VERIFIED against pydantic 2.x + CPython 3.11 in the sec-af venv:
- SEPARATORS. No whitespace at all: `{"a":1,"b":2}`. json.dumps' compact form uses ", " and ": ".
- NON-ASCII. Emitted RAW (ensure_ascii=False): `"héllo 😀"`. json.dumps escapes both as \uXXXX. The three HTML characters `<`, `>` and `&` are also emitted raw — encoding/json would escape them.
- FLOATS. pydantic serializes through Rust, not through CPython's repr(), and the two disagree at the edges — see PydanticFloat. Non-finite values are rendered as `null`, where json.dumps' default allow_nan=True emits the bare NaN/Infinity/-Infinity tokens.
Everything else matches Dumps and is implemented by the same rules:
nil, nil pointer/interface/map/slice -> null
bool -> true / false
int/uint kinds -> decimal integer
float32/float64 -> PydanticFloat (see below): a
decimal form that always carries a
fraction, so 1.0 stays "1.0" where
encoding/json emits "1"
json.Number -> verbatim when integral, else
PydanticFloat of its value
string -> JSON string, raw UTF-8
slice/array -> array
Ordered / KV -> object in INSERTION order
map -> object with SORTED keys
struct -> object, fields in DECLARATION
order (== pydantic field order),
json tags honored
Deliberate deviations, both shared with Dumps and documented there:
- MAP KEY ORDER is sorted, because a Go map carries none. A pydantic model is a struct here, so its field order is exact; only a `dict[str, X]` FIELD is affected, and pydantic emits those in the dict's insertion order. Build a pyfmt.Ordered where the order is load-bearing.
- json.Marshaler is deliberately NOT consulted. `model_dump_json()` uses pydantic's own serializers, not Go's: schemas.Timestamp's MarshalJSON spells a UTC instant "…+00:00" (datetime.isoformat), while model_dump_json spells it "…Z". No model reachable from this function has a datetime field — AuditProgress has none — so rather than guess, the encoder renders such a value structurally and internal/output keeps its own writer for the one model (SecurityAuditResult) that does.
func FormatFloat ¶
FormatFloat reproduces Python's str(f) — identical to repr(f) for floats since Python 3.1, and identical to what json.dumps emits for a float.
The algorithm is CPython's format_float_short in 'r' (repr) mode:
- take the shortest decimal digit string that round-trips,
- let decpt be its decimal exponent (value == 0.<digits> * 10**decpt),
- use exponential notation when decpt <= -4 || decpt > 16, else positional,
- always leave a ".0" on an integral positional result so it reads as a float.
The Go stdlib gets the threshold wrong for this purpose: FormatFloat(f,'g',-1,64) switches to exponential as soon as the exponent reaches the DIGIT COUNT, so 1e15 renders "1e+15" where Python renders "1000000000000000.0", and 100.0 renders "100" where Python renders "100.0".
Ground truth (venv python -c "print(str(v))"):
1.0 -> 1.0 0.1 -> 0.1 1e15 -> 1000000000000000.0 1e16 -> 1e+16 1e-4 -> 0.0001 1e-5 -> 1e-05 -0.0 -> -0.0 inf -> inf nan -> nan
func PydanticFloat ¶
PydanticFloat renders a float exactly as `BaseModel.model_dump_json()` does.
Exported because internal/output's local pydantic writer (which cannot go through DumpsModelJSON — it also has to render ordered dict literals and schemas.Timestamp) needs the identical rule. One implementation, so the two model_dump_json writers in this port cannot spell a float differently.
This is NOT FormatFloat: that reproduces CPython's repr(), and pydantic serializes through Rust instead. VERIFIED against pydantic 2.x in the sec-af venv — the two agree on ordinary magnitudes and disagree in three places:
value repr() model_dump_json() 1e-5 1e-05 0.00001 1e-6 1e-06 1e-6 (no zero-padded exponent) 1e-7 1e-07 1e-7 1e15 1000000000000000.0 (both) 1e16 1e+16 1e+16 (both) -0.0 -0.0 -0.0 (both) inf / nan Infinity / NaN null
The rule: a DECIMAL form (always carrying a fraction, so an integral value gets a trailing ".0") whenever the magnitude is 0 or lies in [1e-5, 1e16); an EXPONENT form otherwise, with the exponent digits unpadded and a "+" kept for a positive exponent — which is what Go's 'e' format produces once its own zero padding is stripped.
func Repr ¶
Repr reproduces Python's repr(v) for the value kinds SEC-AF interpolates: None, bool, int, float, str, list and dict, nested arbitrarily.
nil -> None
true / false -> True / False
42 -> 42
1.0 -> 1.0 (FormatFloat)
"a" -> 'a' (single quotes; see reprString)
[]any{"a","b"} -> ['a', 'b']
Ordered{...} -> {'k': 'v'} (insertion order preserved)
map[string]any -> {'k': 'v'} (keys SORTED — see below)
Python parity caveat for plain maps: Python renders a dict in insertion order, which a Go map does not carry. Rather than emit a random order, Repr sorts map keys so output is deterministic and diffable. Call sites whose bytes must match Python exactly must pass an Ordered built in the Python dict's insertion order.
func Round ¶
Round reproduces Python's built-in round(x, ndigits) for a float argument.
Python parity: this is round-half-to-EVEN ("banker's rounding") applied to the EXACT binary value of the float64, not to its shortest decimal spelling. Go's math.Round is half-away-from-zero, and the common `math.Round(x*p)/p` idiom additionally accumulates error in the x*p product. Both diverge from Python:
round(2.675, 2) == 2.67 (2.675 is really 2.67499999999999982...) round(0.125, 2) == 0.12 (exactly halfway -> ties to even) round(0.375, 2) == 0.38 (exactly halfway -> ties to even) round(1.5) == 2 round(2.5) == 2 round(0.5) == 0
Implementation: CPython's double_round formats x to ndigits decimal places with _Py_dg_dtoa (correctly rounded, ties-to-even) and re-parses the result with _Py_dg_strtod (correctly rounded). math/big.Rat models both steps exactly — SetFloat64 is lossless, the scaled quotient is rounded ties-to-even by hand, and Rat.Float64 rounds to nearest-even like strtod. Unlike strconv.FormatFloat this also handles negative ndigits (round(1234.5678, -2) == 1200.0), which Python accepts and the 'f' verb does not.
Callers that want Python's zero-argument round(x) — which returns an int — use Round(x, 0) and convert; the value is identical (round(2.5) == 2 and Round(2.5, 0) == 2.0).
func Str ¶
Str reproduces Python's str(v).
str() differs from repr() only for strings (str("a") == "a", repr("a") == "'a'"); every container falls through to repr of its elements, which is why str(["a"]) == "['a']". Interpolating a value into an f-string calls str(), so this is the function most prompt builders want.
Types ¶
type Ordered ¶
type Ordered []KV
Ordered is an insertion-ordered mapping, the Go stand-in for a Python dict.
Python dicts preserve insertion order and repr() renders them in that order. A Go map[string]any cannot, so any call site whose repr output is compared byte-for-byte against Python (prompt text, note text) MUST build an Ordered with the same key order the Python dict had. Repr accepts a plain map[string]any too, but see its doc comment for the caveat.