Compare commits

...

3 Commits

Author SHA1 Message Date
Josh Creek 602fa297d0 chore(training): add air_touch_bonus_weight and restart stage-5 intercepts
air_approach_weight alone didn't move productive_air_touch_fraction after a
further 180M steps (360M cumulative across all six Stage-5 attempts): an
unredirected air-intercept ball falls short of the goal from gravity and
just lands on the floor, so the already-solved ground game collects the
same episode reward whether or not anything touched the ball in the air.
air_touch_bonus_weight adds a conjunctive event bonus on top of
ball_touch_reward for a touch that's both genuinely aerial and
goal-directed, targeting the actual measured behaviour instead of only the
approach to it.
2026-08-19 22:46:04 +01:00
CosmicClash Training Bot 03f49e59c8 chore(training): generation 5 progress after 20260819-1321-gen5-s5-intercepts-retry2 2026-08-19 22:34:06 +01:00
CosmicClash Training Bot fde098c6c9 chore(training): Add 20260819-1321-gen5-s5-intercepts-retry2 checkpoints, logs, and exported policy 2026-08-19 22:32:53 +01:00
13 changed files with 126 additions and 175 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+31 -6
View File
@@ -60,6 +60,29 @@ extends AIController3D
# vector rather than the planar-only one, since a real aerial requires
# pitching away from level.
@export var air_approach_weight := 0.0
# Event bonus, conjunctive with the same goal-direction alignment already
# gating ball_touch_reward: extra payout for a touch that is BOTH genuinely
# aerial (ball.y > AIR_TOUCH_HEIGHT, matching the productive_air_touch_
# fraction telemetry definition exactly) AND goal-directed. air_approach_
# weight alone did not move productive_air_touch_fraction (still 0.0 after
# a further 180M steps, 360M cumulative) because nothing in the reward ever
# made touching the ball while still airborne worth more than the
# alternative every policy already had available for free: let gravity pull
# an unredirected air-intercept ball back down (it falls well short of the
# goal's ~0-1.5m height band over the required flight distance, so it does
# not auto-score) and then collect the same goal_reward/ball_touch_reward
# via the already-dominant, already-solved ground game once it lands. This
# is deliberately NOT the standalone height-only bonus generation 4 ruled
# out (see TRAINING.md's "why no air-touch reward" note, added to close the
# RLGym wall-bounce exploit): it only pays scaled by the same alignment
# dot-product as the base term, so batting the ball in a non-productive
# direction earns nothing extra, same anti-farming shape as ball_touch_
# reward itself. Also safe from that specific exploit on distributional
# grounds: air-intercept spawns are central (x in [-8,8], z in [-10,10],
# arena half-extents 18/27) and air-drill spawns keep AIR_DRILL_BALL_WALL_
# CLEARANCE from every wall, so neither state can be solved by bouncing off
# one.
@export var air_touch_bonus_weight := 0.0
@export var ball_velocity_to_goal_weight := 0.004
# Per-tick penalty scaled by distance to the ball (full value at the arena's
# far diagonal, 0 on top of the ball). Run04 lesson: with idling worth a flat
@@ -504,14 +527,16 @@ func _on_ship_body_entered(body: Node) -> void:
var to_goal := attack_goal_position - ball.global_position
if to_goal.length_squared() > 0.0001 and ball.linear_velocity.length_squared() > 0.0001:
alignment = clampf(ball.linear_velocity.normalized().dot(to_goal.normalized()), 0.0, 1.0)
reward += ball_touch_reward * lerpf(ball_touch_direction_floor, 1.0, alignment)
var touch_payout := ball_touch_reward * lerpf(ball_touch_direction_floor, 1.0, alignment)
if air_touch_bonus_weight > 0.0 and ball.global_position.y > AIR_TOUCH_HEIGHT:
touch_payout += air_touch_bonus_weight * alignment
reward += touch_payout
_ticks_since_ball_touch = 0
# Telemetry only (see get_info's air_touch_fraction) — not a reward term.
# Generation 4 deliberately doesn't reward high touches directly (see
# TRAINING.md's "why no air-touch reward" note); this just measures
# whether the air-drill state setter is producing genuine aerial
# contests, so a future decision to add one is data-driven.
# air_touch_fraction/productive_air_touch_fraction (see get_info) share
# their AIR_TOUCH_HEIGHT/alignment definitions 1:1 with air_touch_bonus_
# weight above by design — the reward now targets exactly the behaviour
# the telemetry measures.
_touches += 1
if ball.global_position.y > AIR_TOUCH_HEIGHT:
_air_touches += 1
+2 -1
View File
@@ -265,7 +265,7 @@ const TRAINING_MODE_OVERRIDES := [
const SHIP_AI_OVERRIDES := [
"ball_touch_reward", "ball_touch_cooldown_ticks", "ball_touch_direction_floor",
"velocity_to_ball_weight", "ball_velocity_to_goal_weight", "ball_distance_penalty",
"forward_velocity_to_ball_weight", "air_approach_weight", "wall_contact_penalty", "tilt_penalty",
"forward_velocity_to_ball_weight", "air_approach_weight", "air_touch_bonus_weight", "wall_contact_penalty", "tilt_penalty",
"ground_tilt_penalty", "non_forward_penalty", "grounded_upright_reward",
"speed_reward_weight", "time_penalty", "airborne_penalty",
]
@@ -324,6 +324,7 @@ func _ai_default(name: String) -> Variant:
"velocity_to_ball_weight": return 0.02
"forward_velocity_to_ball_weight": return 0.0
"air_approach_weight": return 0.0
"air_touch_bonus_weight": return 0.0
"ball_velocity_to_goal_weight": return 0.004
"ball_distance_penalty": return 0.002
"wall_contact_penalty": return 0.0025
+28
View File
@@ -662,6 +662,34 @@ were deleted and Stage 5 restarts from Stage 4's checkpoint with the new term,
same reasoning as every previous mechanism change: don't resume a policy
shaped by an absent term into one where it now exists.
**`air_approach_weight` alone did not fix it.** A further three attempts
(180M more steps, 360M cumulative across all six Stage-5 attempts, 2026-08-18
to 2026-08-19) closed with `productive_air_touch_fraction` still exactly 0.0
and `air_touch_fraction` still at noise level, while `goal_rate` kept clearing
its (lower) floor. Working out the actual physics instead of retuning another
number found the real gap: an unredirected `_place_air_intercept` ball
(spawned 6-12m up, aimed at a goal whose collision box sits at ~0-1.5m) sags
well short of the goal from gravity alone over the required flight
distance — it does not auto-score — so it just falls to the floor, where the
already-solved ground game collects the exact same `goal_reward`/
`ball_touch_reward` regardless of whether anything touched it in the air.
Nothing in the reward ever made a genuinely aerial touch worth more than
waiting the second or two for the ball to land, so `air_approach_weight`'s
dense closing-speed shaping had nothing to reinforce — a second,
independent missing-incentive gap in the same stage, not a training-duration
problem. `air_touch_bonus_weight` (`ship_ai_controller.gd`) closes it
directly: an event bonus on top of `ball_touch_reward`, paid only for a
touch that is both above `AIR_TOUCH_HEIGHT` and goal-directed, scaled by the
exact same alignment factor already gating the base touch reward —
conjunctive, not standalone, so it targets exactly the behaviour
`productive_air_touch_fraction` measures without reopening the RLGym
wall-bounce exploit the original "no standalone air-touch reward" decision
(above) was written to avoid: air-intercept/air-drill spawns are kept away
from every wall by construction. Set to 0.5 (roughly `ball_touch_reward`'s
own magnitude) and folded into `HANDLING_REWARD_FLAGS`. The three blocked
attempts were deleted and Stage 5 restarts from Stage 4's checkpoint again
with both terms active.
Stage 6's `league` opponent mode samples a historical exported policy at each
episode reset. Each later stage preserves the preceding shaping and adds one
new difficulty.
+27
View File
@@ -873,5 +873,32 @@
"team_1": 54
},
"win_rate_a": 0.58
},
{
"timestamp": "2026-08-19T21:34:06+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260819-1321-gen5-s5-intercepts-retry2.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 56,
"wins_b": 31,
"draws": 13,
"side_results": {
"a_team_0": {
"wins_a": 25,
"wins_b": 16,
"draws": 9
},
"a_team_1": {
"wins_a": 31,
"wins_b": 15,
"draws": 4
}
},
"physical_team_wins": {
"team_0": 40,
"team_1": 47
},
"win_rate_a": 0.56
}
]
+30
View File
@@ -181,10 +181,40 @@ STANDING_ARGS = ["--ent-coef", "0.01", "--entropy-floor"]
# than continuing retry2's, same reasoning as every previous mechanism
# change in this file: don't resume a policy shaped by an absent term into
# one where it now exists.
#
# Round 8 (2026-08-19): air_approach_weight alone did not move the needle
# either -- another full 180M-step chain (3 more attempts, 360M cumulative
# across all six Stage-5 attempts) closed with productive_air_touch_fraction
# still exactly 0.0 and air_touch_fraction at noise level, while goal_rate
# kept passing its (lower) floor. Working out the physics instead of just
# re-tuning a number found why: an unredirected air-intercept ball (spawned
# 6-12m up, aimed at a goal whose collision box sits at ~0-1.5m) sags well
# short of the goal from gravity alone over the required flight distance --
# it does not auto-score -- so it simply falls to the floor, and the
# already-solved ground game (forward_velocity_to_ball_weight, ball_touch_
# reward, goal_reward) collects the exact same total episode reward either
# way. Nothing ever made touching the ball while it was still genuinely
# airborne worth more than waiting the second or two for it to land, so
# air_approach_weight's dense closing-speed shaping had nothing to reinforce
# -- nowhere near a training-duration problem, a second missing-incentive
# gap in the same stage.
#
# air_touch_bonus_weight (ship_ai_controller.gd) closes it directly: an
# event bonus on top of ball_touch_reward, paid only for a touch that is
# both above AIR_TOUCH_HEIGHT and goal-directed, scaled by the exact same
# alignment factor already gating the base touch reward -- conjunctive, not
# standalone, so it can't be farmed by batting the ball in a useless
# direction, and it targets exactly the behaviour productive_air_touch_
# fraction measures instead of only the approach to it. Set to 0.5 (roughly
# ball_touch_reward's own magnitude, so a fully-aligned aerial touch pays
# ~1.7x a fully-aligned ground one). Also folded into HANDLING_REWARD_FLAGS
# so Stage 6 inherits it. Restarts Stage 5 from Stage 4's checkpoint again,
# same reasoning as every prior mechanism change here.
HANDLING_REWARD_FLAGS = [
"--velocity-to-ball-weight", "0.04",
"--forward-velocity-to-ball-weight", "0.15",
"--air-approach-weight", "0.15",
"--air-touch-bonus-weight", "0.5",
"--ball-distance-penalty", "0.01",
"--ball-touch-reward", "0.7",
"--ball-velocity-to-goal-weight", "0.06",
+1 -166
View File
@@ -1,6 +1,6 @@
{
"stage_index": 1,
"attempt": 2,
"attempt": 0,
"status": "in_progress",
"log": [
{
@@ -258,171 +258,6 @@
],
"reason": "Human override. Only miss was the training goal-rate floor, at 0.7731 vs 0.80; every evaluation gate passed (65-22-13 vs promoted/easy.json, 87% non-draw, 12.6% physical-side imbalance) and the goal rate improved monotonically across all three attempts (0.537 -> 0.683 -> 0.773). Also promoted to Game/bots/promoted/medium.json on the same evidence. Stage 5 resumes from this checkpoint."
}
},
{
"stage_index": 1,
"stage_number": 5,
"stage_name": "intercepts",
"experiment": "20260818-1903-gen5-s5-intercepts",
"attempt": 0,
"telemetry_tail": {
"rollout/air_touch_fraction": 0.00011999999731779099,
"rollout/airborne_fraction": 0.2547863810658455,
"rollout/ep_len_mean": 97.30996008300781,
"rollout/ep_rew_mean": 16.281804374694826,
"rollout/forward_motion_fraction": 0.33076866614818573,
"rollout/goal_rate": 0.8198399951457978,
"rollout/grounded_upright_fraction": 0.1814400002658367,
"rollout/mean_altitude": 3.249552144050598,
"rollout/productive_air_touch_fraction": 0.0,
"rollout/upright_fraction": 0.7921435704231262,
"rollout/vertical_thrust_mean": 0.06410999808754772
},
"telemetry_failures": [
"rollout/productive_air_touch_fraction=0.0000 < 0.0050"
],
"evaluation_goal_failures": [],
"side_balance_failures": [],
"eval": {
"timestamp": "2026-08-19T03:12:42+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260818-1903-gen5-s5-intercepts.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 55,
"wins_b": 30,
"draws": 15,
"side_results": {
"a_team_0": {
"wins_a": 25,
"wins_b": 17,
"draws": 8
},
"a_team_1": {
"wins_a": 30,
"wins_b": 13,
"draws": 7
}
},
"physical_team_wins": {
"team_0": 38,
"team_1": 47
},
"win_rate_a": 0.55
},
"evals": [
{
"timestamp": "2026-08-19T03:12:42+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260818-1903-gen5-s5-intercepts.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 55,
"wins_b": 30,
"draws": 15,
"side_results": {
"a_team_0": {
"wins_a": 25,
"wins_b": 17,
"draws": 8
},
"a_team_1": {
"wins_a": 30,
"wins_b": 13,
"draws": 7
}
},
"physical_team_wins": {
"team_0": 38,
"team_1": 47
},
"win_rate_a": 0.55
}
],
"decision": "fail"
},
{
"stage_index": 1,
"stage_number": 5,
"stage_name": "intercepts",
"experiment": "20260819-0412-gen5-s5-intercepts-retry1",
"attempt": 1,
"telemetry_tail": {
"rollout/air_touch_fraction": 0.00011999999731779099,
"rollout/airborne_fraction": 0.25744442877173424,
"rollout/ep_len_mean": 112.18267991638183,
"rollout/ep_rew_mean": 20.06156894302368,
"rollout/forward_motion_fraction": 0.32834857150912283,
"rollout/goal_rate": 0.7362400003671646,
"rollout/grounded_upright_fraction": 0.17714000023156404,
"rollout/mean_altitude": 3.2925854358673097,
"rollout/productive_air_touch_fraction": 0.0,
"rollout/upright_fraction": 0.7877685700654984,
"rollout/vertical_thrust_mean": 0.09532099807504565
},
"telemetry_failures": [
"rollout/goal_rate=0.7362 < 0.7500",
"rollout/productive_air_touch_fraction=0.0000 < 0.0050"
],
"evaluation_goal_failures": [],
"side_balance_failures": [],
"eval": {
"timestamp": "2026-08-19T12:21:42+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260819-0412-gen5-s5-intercepts-retry1.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 58,
"wins_b": 33,
"draws": 9,
"side_results": {
"a_team_0": {
"wins_a": 26,
"wins_b": 22,
"draws": 2
},
"a_team_1": {
"wins_a": 32,
"wins_b": 11,
"draws": 7
}
},
"physical_team_wins": {
"team_0": 37,
"team_1": 54
},
"win_rate_a": 0.58
},
"evals": [
{
"timestamp": "2026-08-19T12:21:42+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260819-0412-gen5-s5-intercepts-retry1.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 58,
"wins_b": 33,
"draws": 9,
"side_results": {
"a_team_0": {
"wins_a": 26,
"wins_b": 22,
"draws": 2
},
"a_team_1": {
"wins_a": 32,
"wins_b": 11,
"draws": 7
}
},
"physical_team_wins": {
"team_0": 37,
"team_1": 54
},
"win_rate_a": 0.58
}
],
"decision": "fail"
}
]
}
+7
View File
@@ -360,6 +360,12 @@ def parse_args():
help="Aerial mirror of forward-velocity-to-ball-weight: high-altitude dense reward for "
"nose-led 3D closing speed toward the ball",
)
curriculum.add_argument(
"--air-touch-bonus-weight", type=float, default=None,
help="Event bonus on top of ball-touch-reward for a touch that is both genuinely aerial "
"(ball above AIR_TOUCH_HEIGHT) and goal-directed, scaled by the same alignment factor "
"as the base touch reward",
)
curriculum.add_argument(
"--ball-distance-penalty", type=float, default=None,
help="Overrides ShipAIController.ball_distance_penalty (dense per-tick cost scaled by distance to the ball)",
@@ -425,6 +431,7 @@ def _curriculum_kwargs(args) -> dict:
"ai_velocity_to_ball_weight": args.velocity_to_ball_weight,
"ai_forward_velocity_to_ball_weight": args.forward_velocity_to_ball_weight,
"ai_air_approach_weight": args.air_approach_weight,
"ai_air_touch_bonus_weight": args.air_touch_bonus_weight,
"ai_ball_distance_penalty": args.ball_distance_penalty,
"ai_ball_touch_reward": args.ball_touch_reward,
"ai_airborne_penalty": args.airborne_penalty,