18 KiB
Pipeline Roadmap — Reaching the Planned Architecture
This is the engineering breakdown for closing the gap between the current
pipeline and the planned architecture documented in
diagrams/planned/class.puml and
diagrams/planned/activity.puml. Nothing
in diagrams/planned/ is implemented yet — this file tracks what it would
take to get there. For the current implementation, see
README.md.
Items are grouped by layer and roughly ordered by dependency: later groups build on types and services introduced earlier.
The only concurrency in the current pipeline is the log dispatcher thread
(main.cc spawns it to drain a BoundedChannel<LogEntry> into spdlog for the
whole run). Sampling, enrichment, generation, and export all happen
synchronously on the main thread inside BiergartenPipelineOrchestrator::Run()
— §6 below is what introduces the additional worker threads the planned
architecture needs.
1. Domain Models
tooling/pipeline/includes/data_model/generated_models.h and models.h.
- Add
Completenessenum (Full,Partial,Absent) and aLocationContextstruct (text,completeness,char_count). ReplaceEnrichedCity::region_context(currently a plainstd::string) withcontext : LocationContext. - Add
BeerStyle(name,description,min_abv,max_abv,min_ibu,max_ibu). - Add
BeerResult,CheckinResult,RatingResultresult payloads. - Add
GenerationMetadata(generation_id,generated_time,context_provided,generated_with). - Add
first_name,last_name,gender, andactivity_weighttoUserResult(currently justusername,bio).first_name/last_name/genderare copied from the sampledName(see below), not LLM-invented. - Add
Name(first_name,last_name,gender) — the sampled result handed toDataGenerator::GenerateUser. AddForenameEntry(name,gender). Landed as a flatter shape than originally planned here: noNamesByCountrywrapper class.curated_data_service.hinstead declaresForenameList = std::vector<ForenameEntry>andSurnameList = std::vector<std::string>, andICuratedDataServiceexposes two maps keyed by ISO 3166-1 code —ForenamesByCountryMap = unordered_map<string, ForenameList>andSurnamesByCountryMap = unordered_map<string, SurnameList>— directly (see §2). Sampling is a free function,SampleName(forenames_by_country, surnames_by_country, iso3166_1, rng), ingenerate_users.cc's anonymous namespace, not a method on a class. Pairing a forename with a surname happens at sample time so gender (present per-forename in the source data) is never discarded the way a pre-flattened{first_name, last_name}list would lose it; see §2. - Extend
GeneratedBrewerywithbrewery_id,context_completeness,metadata(currently{location, address, brewery}— see theLocation→Cityrename andAddressstruct added in thepostal_regex/§9 entries below). - Add
GeneratedBeer,GeneratedCheckin,GeneratedRating,GeneratedFollowaggregate structs. - Add
UserRecord(location,user : UserResult,email,date_of_birth) as the current stand-in for the plannedGeneratedUser—emailanddate_of_birthare programmatically generated by the orchestrator, never LLM-authored, so a downstream auth-account seeding consumer can register real accounts from the pipeline's SQLite export. Nopassword,user_id, ormetadatafield yet, matchingBreweryRecord's equally pre-metadatashape. Already wired intoIExportService/SqliteExportService: auserstable exists andGenerateUsers()exports each successful record viaProcessRecord(const UserRecord&)(see §5) in addition to logging and holdinggenerated_users_in memory. Still missing vs. the plannedGeneratedUser:user_id,password, andmetadata. - Add
UserPersona(name,description,style_affinities). - Add
postal_regex(std::vector<std::string>) andpostal_code_examples(std::vector<std::string>) toLocation(nowCity— see below), sourced from eachcities.jsonentry'spostal_code.city_regex/postal_code.examples. Not part of the originally plannedLocationshape indiagrams/planned/class.puml— added ahead of postal code generation work; see §9. - Renamed
Location→Citythroughout the pipeline (struct, all parameter/return types,LocationsList→CityList,LoadLocations()→LoadCities()) so the domain vocabulary matches the web backend'sCity/CityId. Field/variable names that merely hold aCityinstance (e.g.EnrichedCity::location,BreweryRecord::location) were left as-is —Cityitself has acityfield (the city name string), so renaming the container field too would read asenriched_city.city.city. The SQLite export layer's own vocabulary (locationstable,location_idcolumn,location_cache_, etc.) was renamed tocities/city_idto match, since it's this pipeline's own throwaway export, not the backend's SQL Server schema (see §5). - Added a new
Addressstruct (postal_codeonly, for now) and aBreweryRecord::addressmember, mirroring the web backend'sBreweryPostLocation(AddressLine1,AddressLine2,PostalCode,CityId). Onlypostal_codeis populated —address_line1/address_line2generation has no fixture data or service yet. Users do not get anAddress: the backend'sUserAccounthas no address fields, onlyBreweryPostLocationdoes.
2. Data Preloading
tooling/pipeline/includes/services/curated_data/, fixture files.
- Extract an interface; have
CuratedJsonDataServiceimplement it. Landed asICuratedDataService(services/curated_data/curated_data_service.h), notDataPreloader—LoadCities(),LoadPersonas(),LoadForenamesByCountry(), andLoadSurnamesByCountry()are all virtual methods returningconst&and take no arguments.CuratedJsonDataService(services/curated_data/curated_json_data_service.h, originally landed asJsonLoaderunderjson_handling/before being renamed and moved) takes aCuratedDataFilePathsDTO (locations_path,personas_path,forenames_path,surnames_path) in its constructor rather than a path perLoad*()call, and memoizes each result in a privatecachestruct (locations,personas,forenames_by_country,surnames_by_country), so a second call on the same instance returns the cached result instead of re-parsing — safe becauseCuratedJsonDataServiceoutlives every call site (owned byBiergartenPipelineOrchestratorviaunique_ptr<ICuratedDataService>for the whole run).main.cc's DI injector also gained aMockCuratedDataService(fixed in-memory data: 4 locations acrossUS/DE/FR/BE, 3 personas, and matching forename/surname sets for those four countries), bound in place ofCuratedJsonDataServiceunder--mocked, mirroring the existingMockEnrichmentService/MockGeneratorpattern. - Add
LoadBeerStyles().beer-styles.jsonalready exists in the repo and is already copied into the Docker image (runpod/Dockerfile), but no loader reads it yet, and the native CMake build doesn't copy it intobuild/at all —CMakeLists.txt's "Runtime Assets" step only copiescities.jsonandprompts/. - Add
LoadPersonas()and authorpersonas.json(doesn't exist yet). - Add name-by-country loading, parsing
tooling/pipeline/forenames-by-country.jsonandsurnames-by-country.json. Landed as two separate methods —LoadForenamesByCountry()andLoadSurnamesByCountry()— each returning a flatForenamesByCountryMap/SurnamesByCountryMap(aliases forunordered_map<string, ForenameList>/unordered_map<string, SurnameList>) keyed by ISO 3166-1 code, rather than one combinedLoadNamesByCountry() : NamesByCountrycall. Both files are vendored verbatim (unmodified, full multinational coverage) fromsigpwned/popular-names-by-country-dataset(CC0) — see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section for provenance. Deliberately not pre-paired or filtered down to just the countries incities.json: keeping the source shape (including per-forename gender) intact means the loader can support more countries later, or gender-aware persona/bio generation, without re-sourcing data. - Parse
postal_code.city_regexandpostal_code.examplesout of eachcities.jsonentry's nestedpostal_codeobject inCuratedJsonDataService::LoadCities(), intoCity::postal_regex/City::postal_code_examples(see §1).MockCuratedDataService's 4 fixed locations carry matching values pulled from the correspondingcities.jsonentries.
3. Policy / Strategy Layer
Entirely new — no includes/policy/ (or equivalent) directory exists today.
ContextStrategyinterface +BreweryContextStrategy/BeerContextStrategy. TodayWikipediaEnrichmentService::GetLocationContexthardcodes a generic"brewing"query and a"beer in {country}"query directly — no per-phase strategy selection.SamplingStrategyinterface +UniformSamplingStrategy, replacing the inlinestd::ranges::sample(...)call inBiergartenPipelineOrchestrator::QueryCitiesWithCountries().BeerSelectionStrategyinterface +RandomBeerSelectionStrategy, to pick styles per brewery from theBeerStylepalette (depends on §1 and §2).CheckinDistributionStrategyinterface +JCurveCheckinStrategy/RandomCheckinStrategy— this is the "Check-In System" item already called out in README.md's Next Steps, made concrete.FollowGenerationStrategyinterface +RandomFollowStrategy/ActivityWeightedFollowStrategy.
4. Data Generation
tooling/pipeline/includes/data_generation/, src/data_generation/.
- Extend the
DataGeneratorinterface withGenerateBeer,GenerateCheckin,GenerateRating(today: onlyGenerateBreweryandGenerateUser). - Implement
LlamaGenerator::GenerateUserfor real, with a retry loop mirroringGenerateBrewery(GBNF grammar,ValidateUserJson, up to 3 attempts with corrective feedback) and a newprompts/USER_GENERATION.md. Changed theDataGenerator::GenerateUsersignature to(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult, matching what the activity diagram actually passes.MockGenerator::GenerateUserupdated to match the new signature too. - Implement
MockGenerator::GenerateBeer/GenerateCheckin/GenerateRating. - Add
IPromptFormatter::ExpectedArchitecture()andLlamaGenerator::ValidateModelArchitecture()so loading a GGUF that doesn't match the configured chat template fails fast instead of silently producing degraded output. - Add prompt template files for beer/checkin/rating generation next to
the existing
prompts/BREWERY_GENERATION.md.
5. Export Service
tooling/pipeline/includes/services/database/, src/services/sqlite/.
IExportService::ProcessRecord(const UserRecord&)andSqliteExportService'suserstable (seekCreateUsersTableSql/kInsertUserSqlinincludes/services/database/sqlite_statement_helpers.h) are implemented —GenerateUsers()exports every successful user alongside its resolvedcity_id.ProcessRecord(const BreweryRecord&)now also bindsBreweryRecord::address.postal_codeinto apostal_codecolumn onbreweries(see §9).- Extend
IExportServicewithProcessBeer,ProcessCheckin,ProcessRating,ProcessFollow(todayProcessRecord(const BreweryRecord&)andProcessRecord(const UserRecord&)exist). - Add
beers,checkins,ratings,followstables toSqliteExportService::InitializeSchema()(cities,breweries, andusersalready exist) — seekCreateCitiesTableSql/kCreateBreweriesTableSql/kCreateUsersTableSqlinincludes/services/database/sqlite_statement_helpers.hfor the existing pattern to follow. - Add a
brewery_cache_alongside the existingcity_cache_, and move from one open transaction for the whole run to per-phase batched commits (BEGIN/COMMIT & BEGINon a batch-size threshold), as shown in the planned activity diagram.
6. Concurrency & Orchestration
tooling/pipeline/includes/concurrency/,
src/biergarten_pipeline_orchestrator/.
BoundedChannel<T>already exists and is production-tested — but it's only wired to the single log channel today. Stand up the per-phase producer/consumer channels (loc_ch,exp_ch, etc.) the planned activity diagram describes, each with a dedicated LLM worker thread and SQLite worker thread.- Rewrite
BiergartenPipelineOrchestrator::Run()from one synchronous pass overgenerated_breweries_into phased methods —RunUserPhase→RunBreweryAndBeerPhase(with aRunBeerPhasesub-step oncebrewery_pool_is populated) →RunCheckinPhase/RunFollowPhase(these two can run in parallel) →RunRatingPhase— backed byuser_pool_,brewery_pool_,beer_pool_,checkin_pool_,follow_pool_. - Honor the structural-concurrency requirement already called out in a
comment on
BiergartenPipelineOrchestrator::Run()inbiergarten_pipeline_orchestrator.h: once real worker threads exist, they must be structurally joined (e.g. viastd::jthread) beforeRun()returns, so no worker logs to a closed channel during teardown.
7. Enrichment
- Decide whether to restore the city/region-specific Wikipedia query
that's currently commented out in
src/services/enrichment/wikipedia/get_summary.cc(GetLocationContext). TheContextStrategywork in §3 is a natural place to reintroduce it viaBreweryContextStrategy::QueriesFor(). - Pre-warm caches at startup (
PreWarmBeerStyleCache,PreWarmLocationCachein the planned activity diagram) instead of fetching lazily per record, so the streaming phases never block on a cold cache mid-run.
8. Fixtures / Build
- Author
personas.json,forenames-by-country.json, andsurnames-by-country.json(see §2). - Fix the
beer-styles.jsonbuild-tree gap: add it to the "Runtime Assets"configure_filestep inCMakeLists.txtso native builds and the Docker image agree on what's available at runtime.
9. Postal Code Generation
tooling/pipeline/includes/services/postal_code/. Not part of the original
diagrams/planned/class.puml sketch — added once Location (now City)
gained postal_regex/postal_code_examples (§1, §2).
- Add the
IPostalCodeServiceinterface —GeneratePostalCode(location : const City&) : std::string. - Add
MockPostalCodeService: ignoreslocation.postal_regexentirely and returnslocation.postal_code_examples.front(). - Implement a real, xeger-based
IPostalCodeService. A xeger generator turns a regex pattern (here, one ofCity::postal_regex) into a random string that matches it — the inverse of matching a string against a regex. Without it, every generated postal code is either a hardcoded mock value or absent entirely; a real implementation is required before postal codes can vary per generated brewery instead of always reusing the same curated example. - Wire
IPostalCodeServiceintomain.cc's Boost.DI injector — bound unconditionally toMockPostalCodeService(no--mockedbranch yet, since a real implementation doesn't exist to switch to; add one once the xeger-based generator above lands, mirroringIEnrichmentService) — and intoBiergartenPipelineOrchestrator::GenerateBreweries(), which callsGeneratePostalCode(location)per brewery and stores the result onBreweryRecord::address(a newAddressstruct — see §1), exported to thebreweries.postal_codeSQLite column (§5). Postal-code generation is per-brewery, not per-city or per-user: users don't have an address concept in the web backend, only breweries do (BreweryPostLocation), soUserRecordwas deliberately not extended.
Suggested build order
The planned activity diagram's own phase notes ("brewerypool is now fully populated. Phase 1b may begin.", etc.) imply a dependency order. Roughly:
- Domain models (§1) and data preloading (§2) — nothing else compiles without these.
- Export schema (§5) — so every later generation phase has somewhere to land its output.
- Policy/strategy layer (§3) and generator interface (§4).
- Concurrency/orchestration rewrite (§6), which is the only piece that actually wires §1–§5 into the phased, parallel pipeline shown in the planned diagrams.
- Enrichment cache pre-warming and the city/region query decision (§7) — useful at any point, but most valuable once phases run concurrently.
- Postal code generation (§9) is independent of the phases above — the
xeger-based generator just needs
City::postal_regex, already in place. Wiring the mock into brewery output has already landed (§9); the real generator can be swapped in once built.