Skip to content

Commit 2750ebb

Browse files
DavidJBiancoclaude
andcommitted
Complete Phase 2.6: Persona-Based Activity Generation
Wire persona data (work hours, risk profile, activity intensity) into the generation engine for realistic temporal activity distributions. - Users only generate events during their work hours (zero outside) - Peak hours produce 150% of base intensity - Lunch hours produce zero events - Risk profile scales intensity: low=0.7x, medium=1.0x, high=1.3x - activity_intensity overrides build dynamic patterns per persona - Backward compatible: no persona = uniform activity at all hours 13 new tests in test_persona_activity.py. 518 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8c45894 commit 2750ebb

3 files changed

Lines changed: 360 additions & 14 deletions

File tree

src/log_generator/generation/activity.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -579,19 +579,61 @@ def generate_bash_command(
579579
self.emitters['bash_history'].emit_event(event_data)
580580
logger.debug(f"Generated bash command: {command} by {user.username} on {system.hostname}")
581581

582-
def get_baseline_pattern(self, persona_name: Optional[str]) -> list[tuple[str, float]]:
582+
def get_baseline_pattern(
583+
self,
584+
persona_name: Optional[str],
585+
persona=None,
586+
) -> list[tuple[str, float]]:
583587
"""Get baseline activity pattern for persona.
584588
589+
Phase 2.6: If persona has activity_intensity overrides, builds
590+
dynamic pattern from those. Otherwise falls back to hardcoded patterns.
591+
585592
Args:
586-
persona_name: Persona name (or None for default)
593+
persona_name: Persona name string (or None for default)
594+
persona: Optional resolved Persona object for dynamic patterns
587595
588596
Returns:
589597
List of (activity_type, probability) tuples
590598
"""
599+
# Phase 2.6: Use activity_intensity overrides if provided
600+
if persona and persona.activity_intensity:
601+
return self._build_pattern_from_intensity(persona.activity_intensity)
602+
603+
# Fall back to hardcoded patterns (Phase 1 behavior)
591604
if persona_name and persona_name.lower() in BASELINE_PATTERNS:
592605
return BASELINE_PATTERNS[persona_name.lower()]
593606
return BASELINE_PATTERNS['default']
594607

608+
def _build_pattern_from_intensity(
609+
self, intensity: dict[str, int]
610+
) -> list[tuple[str, float]]:
611+
"""Convert activity_intensity dict to baseline pattern.
612+
613+
Maps intensity values to probabilities. Higher intensity = higher probability.
614+
615+
Args:
616+
intensity: Dict mapping activity_type to events/hour intensity
617+
618+
Returns:
619+
List of (activity_type, probability) tuples
620+
"""
621+
# Always include logon
622+
pattern: list[tuple[str, float]] = [("logon", 0.9)]
623+
624+
if not intensity:
625+
return pattern
626+
627+
# Normalize intensities to probabilities (cap at 0.95)
628+
max_val = max(intensity.values())
629+
for activity, value in intensity.items():
630+
if activity == "logon":
631+
continue # Already added
632+
prob = min(0.95, value / max_val * 0.8 + 0.1)
633+
pattern.append((activity, prob))
634+
635+
return pattern
636+
595637
def execute_baseline_activity(
596638
self,
597639
user: User,

src/log_generator/generation/engine.py

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
)
2525
from log_generator.generation.ground_truth import GroundTruthGenerator
2626
from log_generator.generation.state_manager import StateManager
27-
from log_generator.models.scenario import Scenario, User, System
27+
from log_generator.models.scenario import Persona, Scenario, User, System
2828
from log_generator.utils.time import parse_duration, parse_iso8601, resolve_time_window
2929

3030
logger = logging.getLogger(__name__)
@@ -249,8 +249,13 @@ def _generate_baseline(self) -> None:
249249

250250
# Generate events for each user this hour
251251
for user in enabled_users:
252+
# Resolve persona for work hours and risk modulation
253+
persona = self._get_user_persona(user)
254+
252255
# Calculate events for this user this hour
253-
num_events = self._calculate_events_for_hour(user)
256+
num_events = self._calculate_events_for_hour(
257+
user, current_hour=current_hour.hour, persona=persona
258+
)
254259

255260
if num_events > 0:
256261
# Distribute events across the hour
@@ -280,26 +285,59 @@ def _barrier_flush_all_emitters(self) -> None:
280285
emitter.barrier_flush()
281286
logger.debug("Barrier flush: all emitters complete")
282287

283-
def _calculate_events_for_hour(self, user: User) -> int:
288+
def _get_user_persona(self, user: User) -> Optional[Persona]:
289+
"""Resolve user.persona string to Persona object.
290+
291+
Args:
292+
user: User whose persona to resolve
293+
294+
Returns:
295+
Persona object or None if not found
296+
"""
297+
if not user.persona or not self.scenario.personas:
298+
return None
299+
for persona in self.scenario.personas:
300+
if persona.name == user.persona:
301+
return persona
302+
return None
303+
304+
def _calculate_events_for_hour(
305+
self,
306+
user: User,
307+
current_hour: Optional[int] = None,
308+
persona: Optional[Persona] = None,
309+
) -> int:
284310
"""Calculate number of events for user this hour.
285311
286-
Applies intensity + variation + persona risk profile to determine
287-
how many events to generate for this user during this hour.
312+
Applies intensity + variation + persona risk profile + work hours
313+
to determine how many events to generate for this user during this hour.
314+
315+
Phase 2.6: Uses persona data for time-of-day modulation and risk scaling.
288316
289317
Args:
290318
user: User to calculate events for
319+
current_hour: Hour of day (0-23) for work hours modulation
320+
persona: Resolved Persona object for risk/work-hours modulation
291321
292322
Returns:
293323
Number of events to generate (>= 0)
294324
"""
295-
# Base intensity
325+
# Base intensity from scenario
296326
intensity_map = {'low': 5, 'medium': 15, 'high': 40}
297327
base_events = intensity_map[self.scenario.baseline_activity.intensity]
298328

299-
# Risk profile adjustment (if persona assigned)
300-
# Note: Phase 1 - persona is just a string name, not full Persona object
301-
# Risk adjustments would require full persona definition (Phase 2+)
302-
# For now, skip risk adjustment since we don't have access to risk_profile
329+
# Phase 2.6: Risk profile multiplier
330+
if persona and persona.risk_profile:
331+
risk_mult = {'low': 0.7, 'medium': 1.0, 'high': 1.3}
332+
base_events = int(base_events * risk_mult.get(persona.risk_profile, 1.0))
333+
334+
# Phase 2.6: Work hours modulation
335+
if persona and persona.work_hours_parsed and current_hour is not None:
336+
whp = persona.work_hours_parsed
337+
if current_hour not in whp['hours']:
338+
return 0 # Outside work hours — no activity
339+
elif current_hour in (whp.get('peak_hours') or []):
340+
base_events = int(base_events * 1.5) # Peak hours: 150%
303341

304342
# Apply variation (random jitter)
305343
variation_map = {'low': 0.10, 'medium': 0.25, 'high': 0.50}
@@ -357,9 +395,9 @@ def _generate_user_activity(self, user: User, event_time: datetime) -> None:
357395
system = random.choice(self.scenario.environment.systems)
358396

359397
# Get baseline pattern for user's persona
360-
# Note: persona is a string (persona name) in Phase 1, not a Persona object
398+
persona = self._get_user_persona(user)
361399
persona_name = user.persona if user.persona else None
362-
pattern = self.activity_generator.get_baseline_pattern(persona_name)
400+
pattern = self.activity_generator.get_baseline_pattern(persona_name, persona=persona)
363401

364402
# Execute activities based on probabilities
365403
for activity_type, probability in pattern:

0 commit comments

Comments
 (0)