diff --git a/roadscore/experiments/ace_chestnut_20260916/ace_worker.py b/roadscore/experiments/ace_chestnut_20260916/ace_worker.py index e3b745b28e..297964117b 100644 --- a/roadscore/experiments/ace_chestnut_20260916/ace_worker.py +++ b/roadscore/experiments/ace_chestnut_20260916/ace_worker.py @@ -31,8 +31,8 @@ if resident and composition_policy!='hook-cache-v1':raise ValueError('Resident m if resident and (G/'session_request.json').exists():raise RuntimeError('Unacknowledged resident request requires owner inspection before restart') from contextlib import nullcontext from resident_session import session_lease,validate_session -from startup_buffer import initial_target -initial_buffer_target=initial_target(os.environ,composition_policy,POLICY.initial_buffer_seconds) +from startup_buffer import initial_target,session_target,SESSION_PROTOCOL +boot_initial_buffer_target=initial_buffer_target=initial_target(os.environ,composition_policy,POLICY.initial_buffer_seconds) from tinygrad import Device profile=selected();preparation_id=f'{profile}_{time.time_ns()}' lock=open(G/'gpu.lock','w');fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB) @@ -74,9 +74,10 @@ try: return {'directory':str(folder),'stem':stem.name,'output_gain':POLICY.output_gain} return record def prepare_session(selection=None): - global profile,base_seed,preparation_id,planned,qualified + global profile,base_seed,preparation_id,planned,qualified,initial_buffer_target if selection is not None: new_profile,new_seed,new_policy,bank_hash=validate_session(selection) + next_buffer_target=session_target(selection,new_policy,boot_initial_buffer_target) if new_policy!=composition_policy or bank_hash!=planned.bank_hash:raise ValueError('Resident conditioning identity changed') if (G/'request.json').exists() or (G/'busy').exists():raise RuntimeError('Pending continuation prevents session reset') next_id=f'{new_profile}_{time.time_ns()}' @@ -84,6 +85,7 @@ try: if next_plan.bank_hash!=bank_hash:raise ValueError('Conditioning bank changed on disk') ready.unlink(missing_ok=True) profile,base_seed,preparation_id,planned=new_profile,new_seed,next_id,next_plan + initial_buffer_target=next_buffer_target qualified=QualifiedGenerator(generate,policy=HOOK_POLICY) session_started=time.monotonic() if selection else BOOT write_json(G/'ace_worker_state.json',{'pid':os.getpid(),'generation_seed':base_seed,'composition_policy':composition_policy,'profile':profile,'phase':'preparing','accepted_chunks':0,'accepted_buffer_seconds':0.,'elapsed_seconds':0.,'resident_reused':bool(selection)}) @@ -106,7 +108,7 @@ try: write_json(G/'ace_worker_state.json',{'pid':os.getpid(),'generation_seed':base_seed,'composition_policy':composition_policy,'profile':profile,'phase':'preparing','accepted_chunks':slot,'accepted_buffer_seconds':len(initial)/48000,'first_accepted_audio_seconds':first_accepted_audio_seconds,'elapsed_seconds':time.monotonic()-session_started}) if slot>8:raise RuntimeError('Initial buffer did not fill within bounded preparation') save_wave(G/'ace_initial.wav',initial);np.save(G/'ace_initial.npy',last) - write_json(G/'ace_initial.json',{'generation_seed':base_seed,'composition_policy':composition_policy,'composer':'ace','startup_seconds':time.monotonic()-session_started,'first_accepted_audio_seconds':first_accepted_audio_seconds,'model_load_seconds':0. if selection else model_load_seconds,'resident_model_load_seconds':model_load_seconds,'worker_uptime_seconds':time.monotonic()-BOOT,'resident_reused':bool(selection),'resident_capable':resident,'resident_model_identity':str(id(c)),'conditioning_bank_sha256':planned.bank_hash if resident else None,'host_peak_rss_kib':resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,'duration':len(initial)/48000,'initial_buffer_target_seconds':initial_buffer_target,'source_identity':'kpop_control','prepared_profile':profile if windowed else 'legacy','preparation_id':preparation_id,'continuation_policy':'quality-gated fixed lookahead','generation':preparation,'prepared_identity':True,'output_gain':POLICY.output_gain,'created_wall':time.time()}) + write_json(G/'ace_initial.json',{'generation_seed':base_seed,'composition_policy':composition_policy,'composer':'ace','startup_seconds':time.monotonic()-session_started,'first_accepted_audio_seconds':first_accepted_audio_seconds,'model_load_seconds':0. if selection else model_load_seconds,'resident_model_load_seconds':model_load_seconds,'worker_uptime_seconds':time.monotonic()-BOOT,'resident_reused':bool(selection),'resident_capable':resident,'resident_session_protocol':SESSION_PROTOCOL if resident else None,'resident_model_identity':str(id(c)),'conditioning_bank_sha256':planned.bank_hash if resident else None,'host_peak_rss_kib':resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,'duration':len(initial)/48000,'initial_buffer_target_seconds':initial_buffer_target,'source_identity':'kpop_control','prepared_profile':profile if windowed else 'legacy','preparation_id':preparation_id,'continuation_policy':'quality-gated fixed lookahead','generation':preparation,'prepared_identity':True,'output_gain':POLICY.output_gain,'created_wall':time.time()}) write_json(G/'ace_worker_state.json',{'pid':os.getpid(),'generation_seed':base_seed,'composition_policy':composition_policy,'profile':profile,'phase':'READY','initial_buffer_seconds':len(initial)/48000}) (G/'request.json').unlink(missing_ok=True);ready.write_text('ace');print('ACE_READY',flush=True) with session_lease(G) if resident else nullcontext():prepare_session() @@ -116,7 +118,7 @@ try: selection=json.loads(session_request.read_text());session_request.unlink() try: with session_lease(G):prepare_session(selection) - write_json(G/'session_result.json',{**selection,'phase':'READY','preparation_id':preparation_id}) + write_json(G/'session_result.json',{**selection,'phase':'READY','preparation_id':preparation_id,'applied_initial_buffer_target_seconds':initial_buffer_target}) except BlockingIOError: write_json(G/'session_result.json',{'id':selection.get('id'),'error':'Playback owns the resident session'}) except Exception as error: diff --git a/roadscore/prototype/SHORT_STARTUP.md b/roadscore/prototype/SHORT_STARTUP.md new file mode 100644 index 0000000000..2c0bb840dc --- /dev/null +++ b/roadscore/prototype/SHORT_STARTUP.md @@ -0,0 +1,22 @@ +# Short-startup controlled candidate + +Normal launches retain the 100-second target. Only `ROADSCORE_TEST_SHORT_STARTUP=1` selects target 40 unless explicitly overridden. Whole accepted chunks quantize that to approximately **54 seconds** (26 + 28), not 40. At the reported current hardware timings (first accepted ~17 seconds, continuation 24.5–25.55 seconds), expected preparation is roughly **42–44 seconds**, compared with observed 91.0–91.3 seconds for 110 seconds of accepted audio. That estimate excludes launcher overhead and quality retries; it is not a measured short-startup result. + +The per-session target is bounded (35–112 in explicit test mode, 80–112 otherwise), transmitted using resident protocol 2, validated before reset, acknowledged as actually applied, and checked against the completed bundle. No quality threshold, seed rule, reroll limit, deadline guard, or hold behavior is weakened. Old workers cannot apply a new target without **one owner-controlled restart after current playback**. Same-target old-worker launches remain compatible. An old worker requesting a different target fails before enqueue; echoing unknown fields cannot masquerade as applying the change. + +`startup_buffer_audit.py` models 20 jobs alternating the supplied 24.5/25.55-second observations, adding 28 seconds accepted music per success. Guard starts at 40 seconds, updates to max(30, observed*1.2)+10, request threshold remains90, accepted-tail hold adds24 seconds. The adverse sequence begins with rejected23-second work followed by a26-second retry if its original deadline permits. This is a **sensitivity simulation**, not a replay of complete hardware trace timestamps. + +| Initial actual reserve | All accepted: minimum / holds | Early rejected job: minimum / holds / fresh jobs | +|---|---|---| +|82 seconds|57.50 / 0|33.00 / 0 / 20| +|54 seconds (40 target)|29.50 / 0|29.45 / 1 / 19| +|40 seconds hypothetical|20.00 / 1|20.00 / 2 / 19| +|30 seconds hypothetical|29.50 / 1|29.45 / 2 / 19| + +All simulated underflow counts are zero **only under instantaneous viable holds and zero callback/scheduling jitter**. These are not hardware underflow predictions. The54-second case cannot admit the early second quality attempt inside the original deadline: it keeps accepted music through a reported hold, then resumes fresh work.30 seconds is deliberately not an allowed target. The existing adaptive GenerationBudget and inflight20-second hold reserve remain unchanged. + +The last supplied real long-run evidence was20 jobs, no retries, minimum64 seconds. A newer observed rejection plus retry took49 seconds. These motivate measuring holds and minimum buffer during the controlled short-startup test; the prior no-retry64-second minimum is not proof the smaller reserve is safe. + +## Additional launcher time + +Source inspection finds no fixed26–34-second sleep. `normal_onroad.py` sequentially seeds route params/acquires display before starting the receiver; `native_receiver.sh` then runs manifest capture and app initialization after preparation, with at most roughly two1-second polling delays plus bridge subscription. In `app.py`, full-source `analyze_music` and full-source `assess_grid` run before `RhythmTimeline.add` assesses overlapping windows again; the standalone grid is immediately replaced by `timeline.at(0)`. Removing that duplicate while preserving required audit fields is a clear candidate. Actual stage timestamps or a labeled offline profile are required to assign the26–34 seconds quantitatively. No app or launcher edit is included here. diff --git a/roadscore/prototype/composition_launch.py b/roadscore/prototype/composition_launch.py index 845c25d04f..40b3097833 100644 --- a/roadscore/prototype/composition_launch.py +++ b/roadscore/prototype/composition_launch.py @@ -15,5 +15,5 @@ def configure(session, replay, composer, *, native, transport_only, environ, roo environ.pop('ROADSCORE_PLANNER_URL', None) environ.pop('ROADSCORE_PLANNER_TOKEN', None) environ.setdefault('ROADSCORE_RESIDENT', '1') - environ.setdefault('ROADSCORE_INITIAL_BUFFER_SECONDS', '100') + environ.setdefault('ROADSCORE_INITIAL_BUFFER_SECONDS', '40' if environ.get('ROADSCORE_TEST_SHORT_STARTUP') == '1' else '100') return 'hook-cache-v1' diff --git a/roadscore/prototype/prepared_session.py b/roadscore/prototype/prepared_session.py index 46d0dc0245..a7100682ba 100644 --- a/roadscore/prototype/prepared_session.py +++ b/roadscore/prototype/prepared_session.py @@ -2,14 +2,27 @@ import json import os from pathlib import Path +from startup_buffer import initial_target,require_session_protocol,SESSION_PROTOCOL def verify(metadata,environ): + if 'ROADSCORE_INITIAL_BUFFER_SECONDS' in environ: + target=initial_target(environ,environ.get('ROADSCORE_COMPOSITION_POLICY','prepared-v1')) + if metadata.get('initial_buffer_target_seconds')!=target:raise ValueError('Prepared audio buffer target differs from requested session') if metadata.get('resident_capable') and environ.get('ROADSCORE_RESIDENT')!='1':raise ValueError('Resident service requires an opted-in fresh handoff and playback lease') if int(metadata.get('generation_seed',-1))!=int(environ['ROADSCORE_GENERATION_SEED']):raise ValueError('Resident composer belongs to another seed; its owner must finish/release that session') if metadata.get('prepared_profile')!=environ.get('ROADSCORE_ACE_PROFILE','prism'):raise ValueError('Resident composer profile differs from this launch') if metadata.get('composition_policy','prepared-v1')!=environ.get('ROADSCORE_COMPOSITION_POLICY','prepared-v1'):raise ValueError('Resident composer policy differs from this launch') + +def requested_buffer(metadata,environ): + if 'ROADSCORE_INITIAL_BUFFER_SECONDS' not in environ:return None + target=initial_target(environ,environ['ROADSCORE_COMPOSITION_POLICY']) + if metadata.get('resident_session_protocol',0)>=SESSION_PROTOCOL:return target + if metadata.get('initial_buffer_target_seconds')==target:return None + require_session_protocol(metadata) + return target + if __name__=='__main__': generated=Path('/data/roadscore/generated') metadata=json.loads((generated/'ace_initial.json').read_text()) @@ -19,6 +32,6 @@ if __name__=='__main__': from resident_session import request_preparation bank=Path(os.environ['ROADSCORE_PLAN_BANK']);profile=os.environ.get('ROADSCORE_ACE_PROFILE','prism') validate_bank(bank,profile) - request_preparation(generated,profile,int(os.environ['ROADSCORE_GENERATION_SEED']),os.environ['ROADSCORE_COMPOSITION_POLICY'],digest(bank/'bank.json')) + request_preparation(generated,profile,int(os.environ['ROADSCORE_GENERATION_SEED']),os.environ['ROADSCORE_COMPOSITION_POLICY'],digest(bank/'bank.json'),initial_buffer_seconds=requested_buffer(metadata,os.environ),short_startup_test=os.environ.get('ROADSCORE_TEST_SHORT_STARTUP')=='1') metadata=json.loads((generated/'ace_initial.json').read_text()) verify(metadata,os.environ) diff --git a/roadscore/prototype/resident_session.py b/roadscore/prototype/resident_session.py index 3c0af262b6..c63d43d242 100644 --- a/roadscore/prototype/resident_session.py +++ b/roadscore/prototype/resident_session.py @@ -7,6 +7,7 @@ from pathlib import Path import re import time import uuid +from startup_buffer import session_target, require_session_protocol, SESSION_PROTOCOL @contextmanager @@ -40,13 +41,20 @@ def validate_session(request): raise ValueError('Resident handoff requires hook-cache-v1') if not isinstance(bank, str) or re.fullmatch('[0-9a-f]{64}', bank) is None: raise ValueError('Resident handoff requires the conditioning bank SHA-256') + session_target(request, policy) return profile, seed, policy, bank -def request_preparation(generated, profile, seed, composition_policy, bank_sha256, timeout=1560): +def request_preparation(generated, profile, seed, composition_policy, bank_sha256, timeout=1560, *, initial_buffer_seconds=None, short_startup_test=False): generated = Path(generated) selection = {'profile': profile, 'generation_seed': seed, 'composition_policy': composition_policy, 'bank_sha256': bank_sha256} + if initial_buffer_seconds is not None: + if short_startup_test:selection['startup_policy']='short-startup-test-v1' + selection.update(session_protocol=SESSION_PROTOCOL, initial_buffer_target_seconds=initial_buffer_seconds) + # Old workers echo unknown request fields: capability must be checked before enqueue. + metadata = json.loads((generated / 'ace_initial.json').read_text()) + require_session_protocol(metadata) validate_session(selection) if not math.isfinite(timeout) or timeout <= 0: raise ValueError('Preparation timeout must be finite and positive') @@ -73,6 +81,8 @@ def request_preparation(generated, profile, seed, composition_policy, bank_sha25 raise RuntimeError(str(result['error'])) if validate_session(result) != validate_session(selection): raise RuntimeError('Resident acknowledgment belongs to a different session') + if initial_buffer_seconds is not None and result.get('applied_initial_buffer_target_seconds') != initial_buffer_seconds: + raise RuntimeError('Resident worker did not acknowledge the applied buffer target') if result.get('phase') != 'READY' or not result.get('preparation_id'): raise RuntimeError('Resident preparation did not report READY with provenance') return result diff --git a/roadscore/prototype/startup_buffer.py b/roadscore/prototype/startup_buffer.py index 92c21feb17..a49f80cfa3 100644 --- a/roadscore/prototype/startup_buffer.py +++ b/roadscore/prototype/startup_buffer.py @@ -9,6 +9,27 @@ def initial_target(environ, composition_policy, default=112.): if composition_policy != 'hook-cache-v1': raise ValueError('Initial buffer override is only supported for local cached planning') value = float(value) - if not math.isfinite(value) or not 80 <= value <= default: - raise ValueError('Validated candidate range is 80 to 112 seconds') + minimum = 35. if environ.get('ROADSCORE_TEST_SHORT_STARTUP') == '1' else 80. + if not math.isfinite(value) or not minimum <= value <= default: + raise ValueError('Startup reserve outside supported range (35–112 only for explicit short-startup test; otherwise 80–112)') return value + + +SESSION_PROTOCOL = 2 + + +def session_target(request, composition_policy, fallback=112.): + """Bound a requested reserve without changing generation/quality policy.""" + if 'initial_buffer_target_seconds' not in request: + return fallback + if request.get('session_protocol') != SESSION_PROTOCOL: + raise ValueError('Per-session buffer target requires resident protocol 2') + value = request['initial_buffer_target_seconds'] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError('Session buffer target must be numeric seconds') + return initial_target({'ROADSCORE_INITIAL_BUFFER_SECONDS': value, 'ROADSCORE_TEST_SHORT_STARTUP': '1' if request.get('startup_policy') == 'short-startup-test-v1' else '0'}, composition_policy) + + +def require_session_protocol(metadata): + if metadata.get('resident_session_protocol', 0) < SESSION_PROTOCOL: + raise RuntimeError('Existing worker cannot change its buffer per session; its owner must restart it once with the new code after playback finishes') diff --git a/roadscore/prototype/startup_buffer_audit.py b/roadscore/prototype/startup_buffer_audit.py new file mode 100644 index 0000000000..3b3c850d08 --- /dev/null +++ b/roadscore/prototype/startup_buffer_audit.py @@ -0,0 +1,36 @@ +"""Offline reserve sensitivity model; no audio, routes, GPU or device operations. +This models buffer arithmetic/guards, not callback timing or actual hold viability. +""" +import json + + +def simulate(initial, jobs=20, reject_first=False): + buffer=float(initial);minimum=buffer;holds=0;underflows=0;accepted=0;estimate=30.;elapsed=0. + for job in range(jobs): + if buffer>90: + elapsed+=buffer-90;buffer=90. + required=estimate+10 + if buffer