/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * * PO box / PMB / Apartado / BP synthesizer adapter. It emits {@linkcode PO_BOX_ADAPTER_ID}. * * A PO box delivery line is mutually exclusive with a street line (USPS Pub 28 / DMM 508), so rows * are generated fresh from a tuple rather than by mutating a street row. */ import { tryParsingJSON } from "@mailwoman/core/json" import { makeLcg } from "@mailwoman/core/random" import { TextSpliterator } from "spliterator" import { stableSourceID } from "#adapters/source-id" import { defaultRecipeSource } from "#recipes/sources" import { poBoxTemplateLocale, REGION_OPTIONAL_LOCALES, synthesizeMilitaryPoBoxRow, synthesizePoBoxRow, type PoBoxBaseTuple, } from "#synthesizers/po-box" import { AddressRole, type AdapterOptions, type CanonicalRow, type CorpusAdapter, SurfaceOrigin } from "#types" /** * Registry id stamped into every row this adapter emits, so a corpus record traces back to its dataset. * * The value comes from `RECIPE_SOURCES` because `recipes/po/box/index.ts` writes rows under the same id. * Two producers of one id have to spell it the same way, or a rewrite of a corpus through that table * moves the recipe's rows and leaves this adapter's behind under a name the table no longer answers for. */ export const PO_BOX_ADAPTER_ID = defaultRecipeSource("synth-po-box") /** * License for synthetic PO-box rows. * * They inherit the terms of the real tuples used to derive them. * * The value is stored in each row's `license` column and read by `licenseNamedIn`, * so it stays byte-for-byte. */ export const PO_BOX_LICENSE = "Synthetic — derived from CC-BY / public-domain input tuples" export interface PoBoxInputRow extends PoBoxBaseTuple { street?: string houseNumber?: string } export interface PoBoxAdapterOptions { /** * How many PO box variants to emit per input tuple, each picking a different leader * (and possibly a different number or noise level); default 1. */ variantsPerInput?: number /** * Probability (0..1) of emitting a PMB-with-street variant when the input has * a street and the locale supports PMB. * The default is 0.15. */ pmbRatio?: number /** * Deterministic seed for reproducible synthesis. * The default is `Date.now()`. */ seed?: number /** * Probability (0..1) per input tuple of additionally emitting one self-contained US * military/diplomatic PO-box row, so military volume scales with the input stream. * The default is 0. */ militaryRatio?: number } export function createPoBoxAdapter(opts: PoBoxAdapterOptions = {}): CorpusAdapter { const variantsPerInput = opts.variantsPerInput ?? 1 const pmbRatio = opts.pmbRatio ?? 0.15 const militaryRatio = opts.militaryRatio ?? 0 return { id: PO_BOX_ADAPTER_ID, defaultLicense: PO_BOX_LICENSE, addressRole: AddressRole.Mailing, // No register asserts these boxes exist. // The rows teach the shape of a post-office box line. register: null, surface: SurfaceOrigin.Invented, description: "Synthetic PO box / PMB / Apartado / Boîte Postale rows. Consumes JSONL of (locality, region, postcode, country) tuples and emits locale-appropriate PO box variants.", async *rows(options: AdapterOptions): AsyncIterable { const random = makeLcg(opts.seed ?? Date.now()) // A non-throwing parse (the `skipped++` below) tolerates malformed rows, // unlike `JSONSpliterator`, which would throw on the first bad line. const lines = TextSpliterator.fromAsync(options.inputPath) let emitted = 0 let skipped = 0 let militarySeq = 0 for await (const line of lines) { if (options.signal?.aborted) break if (options.limit !== undefined && emitted >= options.limit) break const trimmed = line.trim() if (!trimmed) continue const input = tryParsingJSON(trimmed) if (input === null) { skipped++ continue } // Region is required except for region-less locales (NZ: `Private Bag 12, Auckland 1010` has // no region token), where the guard must not discard the tuple as missing region. const regionOptional = input.country ? REGION_OPTIONAL_LOCALES.has(poBoxTemplateLocale(input.country)) : false if (!input.locality || !input.postcode || !input.country || (!input.region && !regionOptional)) { skipped++ continue } if (options.country && options.country !== input.country) continue for (let v = 0; v < variantsPerInput; v++) { const synth = synthesizePoBoxRow(input, { random, pmbRatio }) if (!synth) continue // Include `v` in the locality slot to vary the digest across variants; // `stableSourceID` only accepts `ComponentTag` keys. const sourceID = stableSourceID(PO_BOX_ADAPTER_ID, { locality: `${input.locality}#${v}`, region: input.region, postcode: input.postcode, country: input.country, }) yield { raw: synth.raw, components: synth.components, country: input.country, locale: synth.locale, source: PO_BOX_ADAPTER_ID, source_id: sourceID, corpus_version: "", license: PO_BOX_LICENSE, } emitted++ if (options.limit !== undefined && emitted >= options.limit) break } // US military/diplomatic rows are self-contained and off by default, // so the default random stream and output stay byte-identical. // They are US-only and count against `limit`. const militaryAllowed = !options.country || options.country === "US" if ( militaryRatio > 0 && militaryAllowed && (options.limit === undefined || emitted < options.limit) && random() < militaryRatio ) { const mil = synthesizeMilitaryPoBoxRow({ random }) const sourceID = stableSourceID(PO_BOX_ADAPTER_ID, { po_box: `${mil.components.po_box}#mil${militarySeq++}`, locality: mil.components.locality!, region: mil.components.region!, postcode: mil.components.postcode!, }) yield { raw: mil.raw, components: mil.components, country: "US", locale: mil.locale, source: PO_BOX_ADAPTER_ID, source_id: sourceID, corpus_version: "", license: PO_BOX_LICENSE, } emitted++ } } }, } } /** * The configured adapter instance registered with the corpus builder. */ export const poBoxAdapter = createPoBoxAdapter()